jufi.dev

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.

July 2026 · ~6 min · tool: caddyku

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.yaml

One 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:

~/projects/myapi/docker-compose.yml
services:
  backend:
    build: .
    # listens on 8080 inside the container

One 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:

  1. Patches your compose file — the backend service joins caddy-net and gets container_name: myapi-backend
  2. Writes a caddyku.yaml recording the domain config
  3. Adds the domain to the shared Caddyfile and reloads Caddy

Restart the app so it actually joins the network:

docker compose up -d

Open 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:

caddyku.yaml
service: web
container: jufidev-web

domains:
  - domain: jufi.dev
    upstream: jufidev-web:80
    redirect_www: true

And make the compose file caddy-ready yourself — the three rules: unique container_name, join caddy-net, don't publish ports:

docker-compose.yml
services:
  web:
    build: .
    container_name: jufidev-web
    restart: unless-stopped
    networks:
      - caddy-net

networks:
  caddy-net:
    external: true
    name: caddy-net

Then 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.