As mentioned in this post about garron.me coming back, this site is self-hosted again. I have been self-hosting my sites since 2008 with Go2Linux, back when it was Drupal running on a Linode VPS, through the years I have experimented with Varnish, Nginx and written posts about all of that. This time, this is the setup I am using.

The core idea

Each service lives in its own directory with its own docker-compose.yml. All services share a single external Docker network. A Caddy container sits in front of everything, handling SSL and routing traffic to the right container by hostname, all on the same Docker network.

/home/user/
├── caddy/
│   ├── docker-compose.yml
│   └── Caddyfile
├── myapp/
│   ├── docker-compose.yml
│   └── .env
└── anotherapp/
    ├── docker-compose.yml
    └── .env

The shared network

Create it once:

docker network create web

Every docker-compose.yml that needs to be reachable from Caddy joins this network:

networks:
  web:
    external: true

Containers on the same network can reach each other by their container name. Caddy proxies traffic to myapp:3000 without needing to expose any ports to the host.

Caddy as reverse proxy

Caddy handles SSL automatically via Let's Encrypt. No certbot, no cron jobs, no manual renewals.

A minimal Caddyfile:

myapp.example.com {
    reverse_proxy myapp:3000
}

anotherapp.example.com {
    reverse_proxy anotherapp:8080
}

Caddy's docker-compose.yml:

services:
  caddy:
    image: caddy:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - web

networks:
  web:
    external: true

volumes:
  caddy_data:
  caddy_config:

Each service in its own directory

A typical app docker-compose.yml:

services:
  myapp:
    image: myapp:latest
    container_name: myapp
    restart: unless-stopped
    env_file: .env
    networks:
      - web

networks:
  web:
    external: true

Notice: no ports exposed to the host. Caddy reaches the container through the shared network. The app is not directly accessible from the internet.

Keep secrets in .env and add it to .gitignore. Never commit credentials.

Persistent data

Use named volumes for databases and any data you want to survive container recreations:

services:
  db:
    image: postgres:16
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Bind mounts work too, but named volumes are easier to back up and move.

Updating a service

docker compose pull
docker compose up -d

Two commands. Old container stops, new one starts, Caddy keeps routing traffic. Downtime is a few seconds at most.

What you don't need

  • Kubernetes — designed for fleets of machines, adds enormous complexity for a single VPS
  • Docker Swarm — useful for multi-node setups, overkill for one server
  • A private container registry — Docker Hub free tier is fine for personal projects
  • A separate load balancer — Caddy handles this

When this approach breaks down

This setup works well for a single VPS. If you need to scale horizontally across multiple servers, you'll outgrow it. But for personal projects, small apps, and self-hosted tools, a single well-specced VPS handles more than you'd expect.