guide
One shared Caddy for every Docker project on your VPS
From an empty server to two apps with automatic HTTPS — and what to do when you hit a 502.
I run several projects on one small VPS: my profile site (the one you're reading), a staging environment for an exam platform, and whatever side project I'm poking at that month. All of them are Docker Compose projects. All of them need a domain and HTTPS.
The usual answers are either heavy — one nginx + certbot setup per app — or fragile: one shared reverse proxy config that you SSH into and hand-edit, where one typo takes down every site on the box, not just the one you were adding.
Caddy already solves most of this: automatic HTTPS via Let's Encrypt, clean config, hot reload. What was missing was the workflow — registering and removing domains across many projects without touching the shared Caddyfile by hand. So I built caddyku, a small Go CLI that does exactly that and nothing more.
the idea
~/projects/
├── caddy-proxy/ ← ONE Caddy for the whole VPS (ports 80/443)
│ ├── docker-compose.yml
│ └── Caddyfile ← managed by caddyku
│
├── myapi/
│ ├── docker-compose.yml
│ └── caddyku.yaml ← this project's domains
│
└── mysite/
├── docker-compose.yml
└── caddyku.yamlOne Caddy container owns ports 80/443. Every app joins a shared Docker network
(caddy-net) and never publishes ports to the host. Caddy routes to each app
by container name using Docker's internal DNS. caddyku edits the Caddyfile
and reloads Caddy — that's the whole job.
step 0 — prerequisites
- A VPS with Docker + Docker Compose
- Ports 80 and 443 open in your provider's firewall / security group
- A domain with an A record pointing at the VPS
Install caddyku (single binary, no dependencies):
curl -sSL https://github.com/jufianto/caddyku/releases/latest/download/caddyku_linux_amd64.tar.gz \ | tar -xz && sudo mv caddyku /usr/local/bin/ caddyku version
step 1 — bootstrap the shared proxy (once per server)
caddyku init cd ~/projects/caddy-proxy docker compose up -d
init creates the caddy-proxy Compose project with an empty
Caddyfile. Starting it creates the caddy-net network. You never need to
touch this folder again.
step 2 — first app, the easy way
Say you have an existing app:
services:
backend:
build: .
# listens on 8080 inside the containerOne command wires it up:
cd ~/projects/myapi caddyku init-app \ --service backend \ --container myapi-backend \ --domain api.example.com \ --upstream myapi-backend:8080
This does three things:
- Patches your compose file — the
backendservice joinscaddy-netand getscontainer_name: myapi-backend - Writes a
caddyku.yamlrecording the domain config - Adds the domain to the shared Caddyfile and reloads Caddy
Restart the app so it actually joins the network:
docker compose up -dOpen https://api.example.com. Caddy fetched the Let's Encrypt certificate
automatically — you configured HTTPS zero times.
step 3 — second app, the declarative way
For the next project you can skip the flags: write the config first, commit it to the repo, and let caddyku read it. This is what my own site ships — this exact file serves jufi.dev:
service: web
container: jufidev-web
domains:
- domain: jufi.dev
upstream: jufidev-web:80
redirect_www: trueAnd make the compose file caddy-ready yourself — the three rules: unique container_name, join caddy-net, don't publish ports:
services:
web:
build: .
container_name: jufidev-web
restart: unless-stopped
networks:
- caddy-net
networks:
caddy-net:
external: true
name: caddy-netThen on the server:
docker compose up -d --build caddyku sync Scanning /home/ubuntu/projects for caddyku.yaml files... found: myapi/caddyku.yaml (1 domain(s)) found: jufi.dev/caddyku.yaml (1 domain(s)) Syncing 2 project(s) into Caddyfile Caddy reloaded successfully.
sync scans every project, rebuilds only the blocks it manages, validates the
result inside the Caddy container, and rolls back if anything is wrong. A broken
config never reaches your running proxy. (Every mutating command also takes --dry-run to preview the exact diff first.)
The nice property: since caddyku.yaml and the compose file live in the app's
repo, a brand-new server is just git clone each project, docker compose up -d, and one caddyku sync.
step 4 — when you get a 502
You will, eventually. The classic causes: the container isn't running, or it never joined caddy-net. Instead of guessing:
caddyku status caddy-proxy: running (caddy-proxy) caddy-net: exists (3 container(s) attached) DOMAIN UPSTREAM STATUS api.example.com myapi-backend:8080 OK jufi.dev jufidev-web:80 container not running
It walks the whole chain — proxy → network → each domain's upstream container — and tells you which link broke. It also exits non-zero when something is wrong, so you can put it in health checks.
caddyku list answers the other everyday question (what's routed where)
without reading the raw Caddyfile.
custom caddy config
Simple things are YAML fields — you saw redirect_www above; basic_auth exists for password-protecting a staging site. For anything
fancier, a domain can take a raw body with native Caddy directives (path
routing, custom headers), and caddyku still manages the block boundaries. Anything you
write outside its # BEGIN caddyku / # END caddyku markers is never touched.
wrap up
My whole deploy story now: push to master → CI builds → server pulls and rebuilds the container → done. Caddy and caddyku only get involved when a domain changes, which is one command.
caddyku is open source (MIT), a single Go binary: github.com/jufianto/caddyku. If you run more than one Docker project on a VPS, give it a try — and if it breaks, open an issue and tell me how.