| 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 |
# Static error pages when app is down |
| 46 |
handle_errors { |
| 47 |
@404 expression {err.status_code} == 404 |
| 48 |
handle @404 { |
| 49 |
root * /opt/makenotwork/error-pages |
| 50 |
rewrite * /404.html |
| 51 |
file_server |
| 52 |
} |
| 53 |
@500 expression {err.status_code} == 500 |
| 54 |
handle @500 { |
| 55 |
root * /opt/makenotwork/error-pages |
| 56 |
rewrite * /500.html |
| 57 |
file_server |
| 58 |
} |
| 59 |
handle { |
| 60 |
root * /opt/makenotwork/error-pages |
| 61 |
rewrite * /502.html |
| 62 |
file_server |
| 63 |
} |
| 64 |
} |
| 65 |
|
| 66 |
encode gzip zstd |
| 67 |
|
| 68 |
log { |
| 69 |
output file /var/log/caddy/makenotwork.log |
| 70 |
format json |
| 71 |
} |
| 72 |
} |
| 73 |
|
| 74 |
# Creator custom pages (u.makenot.work). |
| 75 |
# Same app process as the apex — the server's host-dispatch middleware renders |
| 76 |
# sanitized creator HTML/CSS here, keeps this origin cookieless, and sets its own |
| 77 |
# strict CSP (do not duplicate CSP here). TLS is the wildcard *.makenot.work |
| 78 |
# Origin CA cert via cloudflare_tls. |
| 79 |
# |
| 80 |
# This explicit block is REQUIRED: without it, u.makenot.work falls into the |
| 81 |
# on-demand-TLS catch-all (:443) below, whose ask endpoint refuses a cert |
| 82 |
# because u.makenot.work is not a verified custom domain. |
| 83 |
# |
| 84 |
# Requires a Cloudflare DNS record for u.makenot.work (proxy ON), or coverage by |
| 85 |
# a *.makenot.work wildcard DNS record. |
| 86 |
u.makenot.work { |
| 87 |
import cloudflare_tls |
| 88 |
|
| 89 |
# Internal API is unreachable here anyway (host dispatch only serves custom |
| 90 |
# pages + /static), but block it explicitly as defense in depth. |
| 91 |
@internal path /api/internal/* |
| 92 |
respond @internal 404 |
| 93 |
|
| 94 |
reverse_proxy localhost:3000 |
| 95 |
|
| 96 |
header { |
| 97 |
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" |
| 98 |
} |
| 99 |
|
| 100 |
encode gzip zstd |
| 101 |
|
| 102 |
log { |
| 103 |
output file /var/log/caddy/userpages.log |
| 104 |
format json |
| 105 |
} |
| 106 |
} |
| 107 |
|
| 108 |
# Multithreaded forum |
| 109 |
forums.makenot.work { |
| 110 |
import cloudflare_tls |
| 111 |
|
| 112 |
reverse_proxy localhost:3400 |
| 113 |
|
| 114 |
header { |
| 115 |
X-Frame-Options "SAMEORIGIN" |
| 116 |
X-Content-Type-Options "nosniff" |
| 117 |
X-XSS-Protection "1; mode=block" |
| 118 |
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" |
| 119 |
Permissions-Policy "camera=(), microphone=(), geolocation=()" |
| 120 |
Referrer-Policy "strict-origin-when-cross-origin" |
| 121 |
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" |
| 122 |
} |
| 123 |
|
| 124 |
encode gzip zstd |
| 125 |
|
| 126 |
log { |
| 127 |
output file /var/log/caddy/forums.log |
| 128 |
format json |
| 129 |
} |
| 130 |
} |
| 131 |
|
| 132 |
# CDN for free content downloads — reverse-proxies to Hetzner Object Storage. |
| 133 |
# Cloudflare caches responses at the edge (free egress). Origin only hit on cache miss. |
| 134 |
# Requires: S3 bucket policy allowing public s3:GetObject, Cloudflare DNS A record (proxy ON). |
| 135 |
cdn.makenot.work { |
| 136 |
import cloudflare_tls |
| 137 |
|
| 138 |
# Only allow GET (downloads). Block mutations. |
| 139 |
@not_get not method GET HEAD |
| 140 |
respond @not_get 405 |
| 141 |
|
| 142 |
# Prepend bucket name to URI path and proxy to Hetzner Object Storage. |
| 143 |
# Replace BUCKET_NAME with the actual S3 bucket name. |
| 144 |
rewrite * /BUCKET_NAME{uri} |
| 145 |
reverse_proxy https://fsn1.your-objectstorage.com { |
| 146 |
header_up Host fsn1.your-objectstorage.com |
| 147 |
} |
| 148 |
|
| 149 |
header { |
| 150 |
X-Content-Type-Options "nosniff" |
| 151 |
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" |
| 152 |
Access-Control-Allow-Origin "https://makenot.work" |
| 153 |
Access-Control-Allow-Methods "GET, HEAD" |
| 154 |
# Cache-Control is set on the S3 objects themselves (immutable). |
| 155 |
# Cloudflare respects the origin's Cache-Control header. |
| 156 |
} |
| 157 |
|
| 158 |
log { |
| 159 |
output file /var/log/caddy/cdn.log |
| 160 |
format json |
| 161 |
} |
| 162 |
} |
| 163 |
|
| 164 |
# dl.maxj.phd download host retired 2026-06-09 — MNW now serves all downloads |
| 165 |
# (creator product pages / makenot.work DMGs). The maxjphd_tls mTLS snippet and |
| 166 |
# the dl.maxj.phd file_server block were removed with it; the /etc/caddy/maxj-phd-origin* |
| 167 |
# cert/key are now unused on prod and can be deleted there. |
| 168 |
|
| 169 |
# Redirect www to canonical domain |
| 170 |
# Note: makenotwork.com and www.makenotwork.com redirects are handled by |
| 171 |
# Cloudflare Redirect Rules (edge-level, no origin hit needed). |
| 172 |
# Those domains are not covered by the *.makenot.work Origin CA cert. |
| 173 |
# Redirect git subdomain browser visits to web UI |
| 174 |
git.makenot.work { |
| 175 |
import cloudflare_tls |
| 176 |
redir https://makenot.work/git permanent |
| 177 |
} |
| 178 |
|
| 179 |
www.makenot.work { |
| 180 |
import cloudflare_tls |
| 181 |
redir https://makenot.work{uri} permanent |
| 182 |
} |
| 183 |
|
| 184 |
# Custom domains — on-demand TLS via Let's Encrypt. |
| 185 |
# Caddy calls /api/domains/caddy-ask before issuing a cert for any domain. |
| 186 |
# makenot.work subdomains are unaffected (matched by explicit blocks above |
| 187 |
# which use Cloudflare Origin CA + mTLS). |
| 188 |
:443 { |
| 189 |
tls { |
| 190 |
on_demand |
| 191 |
} |
| 192 |
|
| 193 |
# Custom domains connect directly to the origin (no Cloudflare mTLS in front), |
| 194 |
# so any client-supplied CF-Connecting-IP / X-Forwarded-For is forgeable. The |
| 195 |
# app trusts CF-Connecting-IP for rate-limiting, lockouts, and audit logs, so |
| 196 |
# overwrite it with the real TCP peer and strip XFF before proxying — a client |
| 197 |
# can no longer mint fake source IPs to evade per-IP throttles or poison logs. |
| 198 |
reverse_proxy localhost:3000 { |
| 199 |
# Set (replace) CF-Connecting-IP to the real TCP peer — overwrites any |
| 200 |
# value the client sent. Strip X-Forwarded-For so no forged value reaches |
| 201 |
# the app (the app ignores XFF anyway; this is hygiene). |
| 202 |
header_up CF-Connecting-IP {http.request.remote.host} |
| 203 |
header_up -X-Forwarded-For |
| 204 |
} |
| 205 |
|
| 206 |
header { |
| 207 |
X-Content-Type-Options "nosniff" |
| 208 |
Strict-Transport-Security "max-age=31536000; includeSubDomains" |
| 209 |
Referrer-Policy "strict-origin-when-cross-origin" |
| 210 |
} |
| 211 |
|
| 212 |
encode gzip zstd |
| 213 |
|
| 214 |
log { |
| 215 |
output file /var/log/caddy/custom-domains.log |
| 216 |
format json |
| 217 |
} |
| 218 |
} |
| 219 |
|
| 220 |
# HTTP catch-all — redirect to HTTPS (also needed for ACME HTTP-01 challenges) |
| 221 |
:80 { |
| 222 |
redir https://{host}{uri} permanent |
| 223 |
} |
| 224 |
|