The compose.tls.yml override runs a Caddy reverse proxy in front of the CMS, terminating TLS at a real domain with auto-provisioned Let's Encrypt certificates. It composes on top of either the Postgres or SQLite production stack — pick whichever DB mode you want, then add -f compose.tls.yml.
Set DOMAIN, keep Caddyfile beside compose.tls.yml, then layer the TLS override onto the VPS Compose command:
# SQLite + TLS
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.tls.yml -f compose.build.yml up -d --build
# Postgres + TLS
docker compose -f compose.prod.yml -f compose.tls.yml -f compose.build.yml up -d --buildWhen using an accessible published image, omit compose.build.yml and --build.
- A domain you control with DNS A/AAAA records pointing at the server's public IP.
- Ports 80 and 443 open to the public internet (Let's Encrypt HTTP-01 and TLS-ALPN-01 challenges).
- The
Caddyfileandcompose.tls.ymlfiles in your install directory (already in the repo at the root).
Edit .env and set:
DOMAIN=cms.example.com
LETSENCRYPT_EMAIL=ops@example.com # optional but recommended (cert expiry notices)For Postgres installs, keep the same POSTGRES_PASSWORD used by vps.md. SQLite installs do not need a database password.
Postgres + TLS:
docker compose -f compose.prod.yml -f compose.tls.yml -f compose.build.yml up -d --buildSQLite + TLS:
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.tls.yml -f compose.build.yml up -d --buildThe first request to https://cms.example.com triggers cert issuance (takes a few seconds). Cert state persists in the caddy_data named volume across restarts and re-deploys.
- Adds a
caddyservice listening on:80,:443, and:443/udp(HTTP/3). - Auto-provisions a Let's Encrypt certificate for
${DOMAIN}on first request. - Reverse-proxies all traffic to
app:3001over the internal Docker network. - Removes the
apphost port mapping (!reset []) so the only public-facing port is Caddy. The CMS is no longer reachable on:3001from outside; only Caddy can reach it via the docker network. - Sets
PUBLIC_ORIGINtohttps://${DOMAIN}so the CSRF origin check matches the public URL even though Caddy hands the container plain HTTP. OverridePUBLIC_ORIGINdirectly (a comma-separated list) to serve more than one domain. - Sets
TRUSTED_PROXY_CIDRSto the Docker bridge range by default so login rate limits and audit logs use the real client IP from Caddy'sX-Forwarded-For. This is client-IP attribution only and has no bearing on CSRF. If you run Caddy on a custom Docker network, overrideTRUSTED_PROXY_CIDRSwith that network's CIDR.
# HTTPS should serve the admin shell:
curl -I https://cms.example.com/health
# → HTTP/2 200
# Plain HTTP should redirect to HTTPS automatically (Caddy default):
curl -I http://cms.example.com/
# → HTTP/1.1 308 Permanent Redirect
# → location: https://cms.example.com/If cert provisioning fails, check Caddy's logs:
docker compose -f compose.prod.yml -f compose.tls.yml logs caddyCommon issues:
- DNS not propagated —
dig +short cms.example.comfrom outside the server should return your IP. - Port 80 blocked — Let's Encrypt HTTP-01 needs port 80 reachable. UFW/iptables/cloud firewall rules.
- Rate limit — Let's Encrypt limits cert issuance per domain per week. While testing, point Caddy at the staging directory by adding
acme_ca https://acme-staging-v02.api.letsencrypt.org/directoryinside the global{ ... }block ofCaddyfile.
The Caddyfile at the repo root is a template. Common customizations:
Multiple domains:
cms.example.com, www.cms.example.com {
encode zstd gzip
reverse_proxy app:3001
}Basic auth on /admin:
{$DOMAIN} {
encode zstd gzip
@admin path /admin /admin/*
basic_auth @admin {
# Generate with: caddy hash-password
admin $2a$14$...
}
reverse_proxy app:3001
}IP allowlist on /admin:
{$DOMAIN} {
encode zstd gzip
@admin path /admin /admin/*
@trusted remote_ip 203.0.113.0/24
handle @admin {
@block not @trusted
respond @block 403
reverse_proxy app:3001
}
reverse_proxy app:3001
}Static caching for published pages:
The CMS already sets Cache-Control: public, max-age=31536000, immutable on /assets/* (hashed). For additional CDN-friendly caching at the Caddy layer:
@static path *.css *.js *.png *.webp *.woff2
header @static Cache-Control "public, max-age=31536000, immutable"After editing, reload Caddy without restarting:
docker compose -f compose.prod.yml -f compose.tls.yml exec caddy caddy reload --config /etc/caddy/CaddyfileTo go back to plain HTTP on :3001, keep the same database-mode files and remove only the TLS override.
SQLite:
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.tls.yml down
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.build.yml up -d --buildPostgres:
docker compose -f compose.prod.yml -f compose.tls.yml down
docker compose -f compose.prod.yml -f compose.build.yml up -d --buildThe certs in caddy_data are preserved; if you re-enable TLS later, Caddy reuses the existing cert if it's still valid.
- deployment/README.md — deployment overview
- vps.md — VPS Compose install commands
- backup-restore.md — backing up app and Caddy volumes
compose.tls.yml— Caddy service and port overrideCaddyfile— reverse proxy and security headers