| 1 |
# Makenotwork Caddy Configuration |
| 2 |
# Place in /etc/caddy/Caddyfile on the server |
| 3 |
# |
| 4 |
# TLS: Cloudflare Origin CA cert (wildcard *.makenot.work + makenot.work) |
| 5 |
# All HTTPS traffic routed through Cloudflare proxy (origin IP hidden). |
| 6 |
# Authenticated Origin Pulls: only Cloudflare can reach the origin. |
| 7 |
# git.makenot.work redirects browser visits to the web UI. |
| 8 |
# SSH clone uses ssh.makenot.work (proxy OFF in Cloudflare). |
| 9 |
# |
| 10 |
# Custom domains: on-demand TLS via Let's Encrypt (ACME HTTP-01). |
| 11 |
# The ask endpoint validates that the domain is verified before issuing a cert. |
| 12 |
# makenot.work subdomains remain protected by Cloudflare mTLS even with ports open. |
| 13 |
|
| 14 |
{ |
| 15 |
on_demand_tls { |
| 16 |
ask http://localhost:3000/api/domains/caddy-ask |
| 17 |
} |
| 18 |
} |
| 19 |
|
| 20 |
# Shared TLS config: Origin CA cert + Authenticated Origin Pulls (mTLS) |
| 21 |
(cloudflare_tls) { |
| 22 |
tls /etc/caddy/cloudflare-origin.pem /etc/caddy/cloudflare-origin-key.pem { |
| 23 |
client_auth { |
| 24 |
mode require_and_verify |
| 25 |
trusted_ca_cert_file /etc/caddy/cloudflare-authenticated-origin-pull-ca.pem |
| 26 |
} |
| 27 |
} |
| 28 |
} |
| 29 |
|
| 30 |
makenot.work { |
| 31 |
import cloudflare_tls |
| 32 |
|
| 33 |
# Block internal API from external access (CLI uses localhost directly) |
| 34 |
@internal path /api/internal/* |
| 35 |
respond @internal 404 |
| 36 |
|
| 37 |
# Reverse proxy to application (includes /docs routes) |
| 38 |
reverse_proxy localhost:3000 |
| 39 |
|
| 40 |
# Security headers (CSP is set by the app, do not duplicate here) |
| 41 |
header { |
| 42 |
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" |
| 43 |
} |
| 44 |
|
| 45 |
# Branded error pages. 404 and 500 come from the app: they are embedded in |
| 46 |
# the binary (src/routes/pages/public/error_pages.rs), so the copy and brand |
| 47 |
# glyphs cannot drift a release behind the site, and no per-deploy file |
| 48 |
# upload keeps them in sync. |
| 49 |
# |
| 50 |
# The catch-all stays a file read. It fires when the app is unreachable, |
| 51 |
# which is precisely when the app cannot render its own page, so 502.html |
| 52 |
# must exist on disk. Sando ships error-pages/ inside each release, so the |
| 53 |
# root goes through the `current` symlink and follows every deploy. It is NOT |
| 54 |
# /opt/makenotwork/error-pages: that was legacy deploy.sh's REMOTE_DIR, and |
| 55 |
# pointing at it left the app-down page serving Caddy's bare 404 instead. |
| 56 |
handle_errors { |
| 57 |
@404 expression {err.status_code} == 404 |
| 58 |
handle @404 { |
| 59 |
rewrite * /__errors/404.html |
| 60 |
reverse_proxy localhost:3000 |
| 61 |
} |
| 62 |
@500 expression {err.status_code} == 500 |
| 63 |
handle @500 { |
| 64 |
rewrite * /__errors/500.html |
| 65 |
reverse_proxy localhost:3000 |
| 66 |
} |
| 67 |
handle { |
| 68 |
root * /opt/mnw/current/error-pages |
| 69 |
rewrite * /502.html |
| 70 |
file_server |
| 71 |
} |
| 72 |
} |
| 73 |
|
| 74 |
encode gzip zstd |
| 75 |
|
| 76 |
log { |
| 77 |
output file /var/log/caddy/makenotwork.log |
| 78 |
format json |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
# Creator custom pages (u.makenot.work). |
| 83 |
# Same app process as the apex; the server's host-dispatch middleware renders |
| 84 |
# sanitized creator HTML/CSS here, keeps this origin cookieless, and sets its own |
| 85 |
# strict CSP (do not duplicate CSP here). TLS is the wildcard *.makenot.work |
| 86 |
# Origin CA cert via cloudflare_tls. |
| 87 |
# |
| 88 |
# This explicit block is REQUIRED: without it, u.makenot.work falls into the |
| 89 |
# on-demand-TLS catch-all (:443) below, whose ask endpoint refuses a cert |
| 90 |
# because u.makenot.work is not a verified custom domain. |
| 91 |
# |
| 92 |
# Requires a Cloudflare DNS record for u.makenot.work (proxy ON), or coverage by |
| 93 |
# a *.makenot.work wildcard DNS record. |
| 94 |
u.makenot.work { |
| 95 |
import cloudflare_tls |
| 96 |
|
| 97 |
# Internal API is unreachable here anyway (host dispatch only serves custom |
| 98 |
# pages + /static), but block it explicitly as defense in depth. |
| 99 |
@internal path /api/internal/* |
| 100 |
respond @internal 404 |
| 101 |
|
| 102 |
reverse_proxy localhost:3000 |
| 103 |
|
| 104 |
header { |
| 105 |
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" |
| 106 |
} |
| 107 |
|
| 108 |
encode gzip zstd |
| 109 |
|
| 110 |
log { |
| 111 |
output file /var/log/caddy/userpages.log |
| 112 |
format json |
| 113 |
} |
| 114 |
} |
| 115 |
|
| 116 |
# Multithreaded forum |
| 117 |
forums.makenot.work { |
| 118 |
import cloudflare_tls |
| 119 |
|
| 120 |
reverse_proxy localhost:3400 |
| 121 |
|
| 122 |
header { |
| 123 |
X-Frame-Options "SAMEORIGIN" |
| 124 |
X-Content-Type-Options "nosniff" |
| 125 |
X-XSS-Protection "1; mode=block" |
| 126 |
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" |
| 127 |
Permissions-Policy "camera=(), microphone=(), geolocation=()" |
| 128 |
Referrer-Policy "strict-origin-when-cross-origin" |
| 129 |
Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self' https://makenot.work" |
| 130 |
} |
| 131 |
|
| 132 |
encode gzip zstd |
| 133 |
|
| 134 |
log { |
| 135 |
output file /var/log/caddy/forums.log |
| 136 |
format json |
| 137 |
} |
| 138 |
} |
| 139 |
|
| 140 |
# CDN for promoted public images: reverse-proxies to Hetzner Object Storage. |
| 141 |
# Cloudflare caches responses at the edge (free egress). Origin only hit on cache miss. |
| 142 |
# Requires: public-read s3:GetObject policy on the public bucket, Cloudflare DNS A |
| 143 |
# record for cdn.makenot.work (proxy ON), and CDN_BASE_URL=https://cdn.makenot.work |
| 144 |
# so the app emits edge URLs rather than direct-origin ones. |
| 145 |
cdn.makenot.work { |
| 146 |
import cloudflare_tls |
| 147 |
|
| 148 |
# Only allow GET (downloads). Block mutations. |
| 149 |
@not_get not method GET HEAD |
| 150 |
respond @not_get 405 |
| 151 |
|
| 152 |
# Prepend the bucket name to the URI path and proxy to Hetzner Object Storage. |
| 153 |
# This bucket MUST be the one named by the app's S3_PUBLIC_BUCKET, and MUST NOT |
| 154 |
# be S3_BUCKET: there is no signature check here, so pointing it at the main |
| 155 |
# bucket would serve every paid download unsigned. Only immutably-public image |
| 156 |
# content (covers, gallery, item/project images) is promoted into the public |
| 157 |
# bucket; audio, video, and downloads stay private and are always presigned by |
| 158 |
# the app (see src/routes/storage/downloads.rs). |
| 159 |
rewrite * /makenotwork-public{uri} |
| 160 |
reverse_proxy https://fsn1.your-objectstorage.com { |
| 161 |
header_up Host fsn1.your-objectstorage.com |
| 162 |
} |
| 163 |
|
| 164 |
header { |
| 165 |
X-Content-Type-Options "nosniff" |
| 166 |
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" |
| 167 |
Access-Control-Allow-Origin "https://makenot.work" |
| 168 |
Access-Control-Allow-Methods "GET, HEAD" |
| 169 |
# Cache-Control is set on the S3 objects themselves (immutable). |
| 170 |
# Cloudflare respects the origin's Cache-Control header. |
| 171 |
} |
| 172 |
|
| 173 |
log { |
| 174 |
output file /var/log/caddy/cdn.log |
| 175 |
format json |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
# dl.maxj.phd download host retired 2026-06-09. MNW now serves all downloads |
| 180 |
# (creator product pages / makenot.work DMGs). The maxjphd_tls mTLS snippet and |
| 181 |
# the dl.maxj.phd file_server block were removed with it; the /etc/caddy/maxj-phd-origin* |
| 182 |
# cert/key are now unused on prod and can be deleted there. |
| 183 |
|
| 184 |
# Redirect www to canonical domain |
| 185 |
# Note: makenotwork.com and www.makenotwork.com redirects are handled by |
| 186 |
# Cloudflare Redirect Rules (edge-level, no origin hit needed). |
| 187 |
# Those domains are not covered by the *.makenot.work Origin CA cert. |
| 188 |
# Redirect git subdomain browser visits to web UI |
| 189 |
git.makenot.work { |
| 190 |
import cloudflare_tls |
| 191 |
redir https://makenot.work/git permanent |
| 192 |
} |
| 193 |
|
| 194 |
www.makenot.work { |
| 195 |
import cloudflare_tls |
| 196 |
redir https://makenot.work{uri} permanent |
| 197 |
} |
| 198 |
|
| 199 |
# Custom domains: on-demand TLS via Let's Encrypt. |
| 200 |
# Caddy calls /api/domains/caddy-ask before issuing a cert for any domain. |
| 201 |
# makenot.work subdomains are unaffected (matched by explicit blocks above |
| 202 |
# which use Cloudflare Origin CA + mTLS). |
| 203 |
:443 { |
| 204 |
tls { |
| 205 |
on_demand |
| 206 |
} |
| 207 |
|
| 208 |
# Custom domains connect directly to the origin (no Cloudflare mTLS in front), |
| 209 |
# so any client-supplied CF-Connecting-IP / X-Forwarded-For is forgeable. The |
| 210 |
# app trusts CF-Connecting-IP for rate-limiting, lockouts, and audit logs, so |
| 211 |
# overwrite it with the real TCP peer and strip XFF before proxying, a client |
| 212 |
# can no longer mint fake source IPs to evade per-IP throttles or poison logs. |
| 213 |
reverse_proxy localhost:3000 { |
| 214 |
# Set (replace) CF-Connecting-IP to the real TCP peer, overwrites any |
| 215 |
# value the client sent. Strip X-Forwarded-For so no forged value reaches |
| 216 |
# the app (the app ignores XFF anyway; this is hygiene). |
| 217 |
header_up CF-Connecting-IP {http.request.remote.host} |
| 218 |
header_up -X-Forwarded-For |
| 219 |
} |
| 220 |
|
| 221 |
header { |
| 222 |
X-Content-Type-Options "nosniff" |
| 223 |
Strict-Transport-Security "max-age=31536000; includeSubDomains" |
| 224 |
Referrer-Policy "strict-origin-when-cross-origin" |
| 225 |
} |
| 226 |
|
| 227 |
encode gzip zstd |
| 228 |
|
| 229 |
log { |
| 230 |
output file /var/log/caddy/custom-domains.log |
| 231 |
format json |
| 232 |
} |
| 233 |
} |
| 234 |
|
| 235 |
# HTTP catch-all: redirect to HTTPS (also needed for ACME HTTP-01 challenges) |
| 236 |
:80 { |
| 237 |
redir https://{host}{uri} permanent |
| 238 |
} |
| 239 |
|