Some links on this page are affiliate links: if you sign up through them, this site earns a commission at no extra cost to you. Prices and recommendations are not affected.

This site runs on one small VPS. On the same box: a static blog, a self-hosted analytics stack, two internal APIs and a password-protected app. One Caddy container terminates TLS for all of them, renews certificates without being asked, and has needed exactly zero manual attention since it was set up.

This post is that setup, config file included, plus the two mistakes we made along the way. If you want the hardware side first, we compared the options in Best VPS for self-hosting in 2026 - everything below runs comfortably on the cheapest tier there.

Why Caddy instead of nginx

nginx is excellent and we still run it as the static file server inside this stack. But for the edge, Caddy wins on one thing that matters at this scale: TLS is not a project. No certbot, no renewal cron, no --dry-run rehearsal at 2 a.m. You write a hostname, Caddy obtains the certificate on first request and renews it in the background.

The trade-off is real and worth knowing: fewer tuning knobs, a smaller ecosystem of copy-paste configs, and a config language that is its own thing rather than nginx syntax you already know. For a single-box setup, that trade is strongly in your favour.

The Compose file

Five services, one network, no orchestration:

services:
  blog:
    image: nginx:alpine
    container_name: blog
    restart: unless-stopped
    volumes:
      - ./site:/usr/share/nginx/html:ro

  analytics-db:
    image: postgres:17-alpine
    container_name: analytics-db
    restart: unless-stopped
    env_file: [.env.analytics]
    volumes:
      - analytics_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U analytics"]
      interval: 10s
      timeout: 5s
      retries: 10

  analytics:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: analytics
    restart: unless-stopped
    env_file: [.env.analytics]
    depends_on:
      analytics-db:
        condition: service_healthy

  caddy:
    image: caddy:2
    container_name: caddy
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - ./caddy:/etc/caddy:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  analytics_db:
  caddy_data:
  caddy_config:

Four details in there are load-bearing:

Only Caddy publishes ports. Nothing else binds to the host. The blog and the analytics app are reachable on the Compose network by container name and nowhere else. This is the single biggest security win of the layout: you cannot accidentally expose a database to the internet if it has no published port.

caddy_data is a named volume, not a bind mount. It holds the certificates and the ACME account key. Lose it and you re-issue everything on next start, which is survivable but counts against rate limits if you do it repeatedly while debugging.

The database has a healthcheck and the app waits on it. Without condition: service_healthy, the app container starts first, fails to connect, and restarts in a loop until Postgres is up. It converges either way, but the logs are a mess and a first deploy looks broken when it is not.

restart: unless-stopped, not always. The difference shows up when you deliberately stop a container to debug it and then reboot the host: always brings it back and overrides your intent.

The Caddyfile

One block per hostname. This is the real structure, with our specifics swapped for placeholders:

blog.example.com {
    encode zstd gzip

    header {
        X-Content-Type-Options nosniff
        Referrer-Policy no-referrer-when-downgrade
        -Server
    }

    # fingerprinted assets can be cached hard, html stays revalidated
    @immutable path /assets/* /favicon*.png /apple-touch-icon.png /og-default.png
    header @immutable Cache-Control "public, max-age=31536000, immutable"
    header /favicon.ico Cache-Control "public, max-age=604800"

    reverse_proxy blog:80
}

stats.example.com {
    encode zstd gzip
    header -Server
    reverse_proxy analytics:3000
}

app.example.com {
    basic_auth {
        admin $2a$14$...bcrypt hash here...
    }
    encode zstd gzip
    reverse_proxy app:3000
}

That is the entire TLS configuration: there isn’t any. Each hostname gets a certificate on first request, renewed automatically, with HTTP redirected to HTTPS for free.

Three things worth copying:

-Server removes the server header. Minor, but free. Caddy announces itself by default.

The cache split matters more than it looks. Hashed asset filenames can be cached for a year because a content change produces a new filename. HTML must not be, or visitors keep reading a stale page after you publish. Getting this backwards is the classic “I deployed but nothing changed” bug, and it is invisible to you because your own browser revalidates on reload.

basic_auth takes a bcrypt hash, not a password. Generate it with docker exec caddy caddy hash-password. Never put the plaintext in the file.

Redirects as a config concern

Our affiliate links never point at a partner directly. Every one goes through a redirect defined in the edge config:

    redir /go/vendor-a* https://www.vendor-a.example/ 302
    redir /go/vendor-b* https://www.vendor-b.example/ 302
    redir /go/* /affiliate-disclosure/ 302

Two reasons, and the second is the one nobody thinks of until it bites:

  1. Partner URLs change. Tracking parameters get reissued, programmes move domains. With this layout you edit one line and reload Caddy; without it you rewrite every post that ever linked there.
  2. The catch-all protects you. /go/* falling through to the disclosure page means a typo or a retired programme produces a real page, not a 404 in the middle of a monetised article.

Keep the trailing * on each pattern so query strings survive the hop, and keep 302 rather than 301: a permanent redirect to a partner URL you will eventually change is cached in browsers you do not control.

Two mistakes we actually made

Bind-mounting a single file pinned a stale inode. The Caddyfile was mounted as ./Caddyfile:/etc/caddy/Caddyfile. Edits on the host stopped reaching the container: Docker bind-mounts a file by inode, and most editors write a new file and rename it over the old one, which leaves the container holding the deleted original. The site served an old config for days with nothing in the logs to say so. Fix: mount the directory, not the file, as in the Compose file above.

An unrestricted deploy key, still in use. The blog’s own deploy key is locked to a single rsync target, but an older CI key for another app on the same box was created with no restrictions at all: whatever holds it gets an interactive shell, not write access to one directory. It has worked perfectly for months, which is exactly why nobody looked at it. Audit every line in authorized_keys, not just the one you set up most recently - the next post is about getting that right.

Operating it

The whole maintenance surface is three commands:

docker compose up -d          # apply changes
docker exec caddy caddy reload --config /etc/caddy/Caddyfile   # config only, no downtime
docker compose logs -f caddy  # watch the ACME dance on a new hostname

Add a new site by adding a service and a hostname block, then reload. First request to the new name takes a second or two while the certificate is issued; after that it is indistinguishable from the rest.

One caveat on certificates: if your DNS sits behind a proxy that terminates TLS itself, the ACME challenge Caddy prefers can fail in ways that look like a Caddy bug. Point the record straight at the box while you are setting up, then put the proxy back if you want it.

What’s next

The companion piece: deploying to this box from GitHub Actions without giving CI a shell - the restricted-rsync setup we should have used from day one.