Last updated: November 2026 — pin image tags you tested; keep one ACME client per public IP.
For: One cheap Linux VPS running several Docker Compose apps that all need HTTPS on the same IPv4/IPv6. You want a single Caddy process, one Let's Encrypt account, and one place to open ports 80/443.
Do not use this guide when:
Each app already publishes its own Caddy/Traefik and you cannot free host ports 80/443
You need per-app Cloudflare Tunnel / zero open ports (different pattern)
Apps live on different VPS hosts (then each host needs its own edge, or a true load balancer)
You insist on Traefik Docker labels as the only config style (see our Caddy vs Traefik comparison)
Approach
ACME / certs
Port 80/443
Typical failure
Caddy inside every app stack
Multiple clients race on same IP
bind: address already in use
Second stack never gets a cert
Shared Caddy (this guide)
One client, many site blocks
Published once
Wrong upstream hostname / wrong Compose network
Host nginx + containers
Host owns certs
Published once
Easy to drift from Compose; not our site standard
Rule on this site: only one process on the host binds 80/443. Apps stay on the internal Docker network.
Depends on the apps, not on Caddy (Caddy idle is usually tens of MB).
Stack combo
RAM floor
Disk note
Vaultwarden + Paperless-ngx
2–4 GB
Paperless consume + media
Vaultwarden + Immich (ML off)
4 GB
Library size dominates
Vaultwarden + Paperless + Immich (ML on)
8 GB+
Immich alone wants 6 GB with ML
CPU
2 vCPU
4 nicer under Immich upload + OCR
Internet → :80/:443 (Caddy)
├─ vault.example.com → vaultwarden:80
├─ docs.example.com → paperless-webserver:8000
└─ photos.example.com → immich-server:2283
App compose projects join an external Docker network: proxy
Caddy compose project also joins: proxy
docker network create proxy
~/edge-caddy/
docker-compose.yml
Caddyfile
.env
caddy-data/
caddy-config/
ACME_EMAIL=you@example.com
VAULT_DOMAIN=vault.example.com
PAPERLESS_DOMAIN=docs.example.com
IMMICH_DOMAIN=photos.example.com
{
email {$ACME_EMAIL}
}
{$VAULT_DOMAIN} {
encode gzip
reverse_proxy vaultwarden:80 {
header_up X-Real-IP {remote_host}
}
}
{$PAPERLESS_DOMAIN} {
encode gzip
reverse_proxy paperless-webserver:8000
}
{$IMMICH_DOMAIN} {
encode gzip
reverse_proxy immich-server:2283
}
Omit any site block you are not running yet. Caddy will still obtain certs only for hostnames that resolve here and answer HTTP-01.
services:
caddy:
image: caddy:2.11.7
container_name: edge_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
ACME_EMAIL: "${ACME_EMAIL}"
VAULT_DOMAIN: "${VAULT_DOMAIN}"
PAPERLESS_DOMAIN: "${PAPERLESS_DOMAIN}"
IMMICH_DOMAIN: "${IMMICH_DOMAIN}"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- proxy
networks:
proxy:
external: true
cd ~/edge-caddy && docker compose up -d
In the Vaultwarden compose file:
Remove any caddy service and any host ports: on Vaultwarden
Add:
services:
vaultwarden:
# ... image, env, volumes as in the Vaultwarden guide ...
expose:
- "80"
networks:
- default
- proxy
networks:
proxy:
external: true
Keep DOMAIN=https://vault.example.com on Vaultwarden itself.
services:
webserver:
# container_name must match Caddy upstream OR change Caddyfile
container_name: paperless-webserver
expose:
- "8000"
networks:
- default
- proxy
environment:
PAPERLESS_URL: "https://${PAPERLESS_DOMAIN}"
networks:
proxy:
external: true
services:
immich-server:
container_name: immich_server
# Caddyfile above uses immich-server — either rename container_name
# to immich-server, or change reverse_proxy to immich_server:2283
expose:
- "2283"
networks:
- default
- proxy
networks:
proxy:
external: true
Hostname rule: reverse_proxy targets the Docker DNS name (service name or container_name) on the shared proxy network. If Caddy and the app cannot resolve each other, you get 502.
Host
Record
Target
vault.example.com
A/AAAA
VPS IP
docs.example.com
A/AAAA
same IP
photos.example.com
A/AAAA
same IP
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
Only the edge Caddy project publishes these ports.
Knob
Must match
DNS hostname
Caddy site address
App public URL env (DOMAIN, PAPERLESS_URL, Immich public URL)
https:// + same hostname
reverse_proxy target
Reachable name on proxy network + internal port
Host ports 80/443
Bound only by edge_caddy
Edge
tar -czf edge-caddy-$(date +%F).tar.gz -C ~/edge-caddy Caddyfile .env caddy-data
Certs live under caddy-data; losing them forces re-issue (fine if DNS still points here)
Apps — use each guide’s backup (Vaultwarden vw-data, Paperless media+db, Immich library + pg_dump). The shared Caddy backup does not replace app data backups.
Restore edge: recreate proxy network → restore files → docker compose up -d → bring apps up with proxy attached.
Symptom
Cause
Fix
bind: address already in use on 80/443
Second Caddy/nginx still published
Remove per-app Caddy; ss -tlnp \| grep -E ':80|:443'
Caddy 502 Bad Gateway
Upstream name wrong or not on proxy
docker network inspect proxy; fix service/container name
Cert OK for one host, fail for another
DNS missing for that hostname
Add A/AAAA; wait TTL; docker compose logs caddy
Vaultwarden sync broken behind HTTPS
App DOMAIN still http://
Set https://vault.example.com
Paperless CSRF / login loops
PAPERLESS_URL mismatch
Match exact public HTTPS URL
Immich mobile cannot upload
Hitting old :2283 publish
Remove host publish; use HTTPS hostname only
Two stacks both define networks.proxy as non-external
Duplicate networks, Caddy cannot see app
Use external: true everywhere after docker network create proxy
ACME rate limits after flapping DNS
Repeated failed issuances
Fix DNS first; avoid wiping caddy-data unless needed
[ ] docker network inspect proxy lists edge_caddy and each app container
[ ] curl -I https://vault.example.com / docs… / photos… → success with valid certs
[ ] No other host process owns 80/443
[ ] Each app’s “public URL” setting uses https:// and the matching hostname
[ ] Reboot: edge-caddy and apps return; all three hostnames still TLS-OK
DNS A/AAAA for the new hostname
New site block in Caddyfile
App joins proxy, expose only, no host ports
docker compose reload or up -d in ~/edge-caddy
Set the app’s canonical HTTPS URL env
Images: caddy:2.11.7. App images stay pinned in their own guides.