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.