Run a multi-container app with Docker Compose and a reverse proxy
One compose.yaml with an app, its PostgreSQL database and Caddy for HTTPS, on private networks with named volumes, health checks, secrets, updates and backups.
Tested on Ubuntu 24.04 LTS
Before you start
- A server with Docker Engine and the Compose plugin
- A domain or subdomain you can point at the server
- Root or a user in the docker group
To run a multi-container app behind a reverse proxy with Docker Compose, put the app, its database and Caddy in one compose.yaml, publish only Caddy's ports 80 and 443, and connect the database to an internal network that nothing outside can reach. Named volumes keep the data across updates, a health check makes the app wait for its database, and the passwords live in a .env file next to the Compose file.
The example app is Miniflux, a small RSS reader that needs PostgreSQL, because it is a real app you can open in a browser at the end. The same layout works for almost any web app with a database: swap the image and its settings.
| What | In this tutorial |
|---|---|
| The domain | rss.example.com |
| The server's public IPv4 address | 203.0.113.10 |
| The project directory | /opt/miniflux |
How the pieces fit
Three containers and two networks:
| Service | Image | Networks | Published ports |
|---|---|---|---|
caddy |
caddy:2.11.7-alpine |
proxy |
80, 443 (TCP and UDP) |
miniflux |
miniflux/miniflux:2.3.4 |
proxy, backend |
none |
db |
postgres:18.6-alpine |
backend |
none |
Caddy is the only container the internet can reach. It finds the app by its service name, miniflux, over the proxy network. The database sits on backend, which is marked internal: containers on it can talk to each other but have no route out, and nothing on proxy can see it. The app is on both, so it can answer Caddy and reach its database.
Pinning exact image tags (2.3.4, not latest) means an update only happens when you change the tag, so you can read the release notes first.
Point the domain at the server
At your DNS provider, add an A record for rss.example.com with the server's IPv4 address, and an AAAA record for its IPv6 address if it has one. Caddy needs this before it can get a certificate. Check it from the server:
getent hosts rss.example.comIt should print your server's address.
Then open ports 80 and 443. Caddy uses 80 for the Let's Encrypt challenge and the redirect, 443 for HTTPS, and UDP 443 for HTTP/3. With ufw:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udpDocker publishes ports by writing its own iptables rules, ahead of ufw's, so a published port is reachable even if ufw would block it. That is why this file publishes nothing except Caddy. Install Docker explains it in more detail.
Create the project directory and the secrets
Compose names the project, its networks and its volumes after the directory, so give each app its own:
sudo mkdir -p /opt/miniflux
cd /opt/minifluxWrite the settings and passwords to .env. Compose reads it automatically and substitutes the values into compose.yaml:
sudo tee .env > /dev/null <<EOF
DOMAIN=rss.example.com
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$(openssl rand -base64 18)
POSTGRES_PASSWORD=$(openssl rand -hex 24)
EOF
sudo chmod 600 .env
sudo cat .envNote the admin password; you log in with it later. The chmod 600 keeps the file readable by root only. Keep .env out of Git; commit compose.yaml and the Caddyfile instead.
Write compose.yaml
Create /opt/miniflux/compose.yaml:
services:
caddy:
image: caddy:2.11.7-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
depends_on:
- miniflux
miniflux:
image: miniflux/miniflux:2.3.4
restart: unless-stopped
environment:
DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db/miniflux?sslmode=disable
RUN_MIGRATIONS: "1"
CREATE_ADMIN: "1"
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BASE_URL: https://${DOMAIN}
networks:
- proxy
- backend
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
interval: 30s
timeout: 5s
retries: 3
db:
image: postgres:18.6-alpine
restart: unless-stopped
environment:
POSTGRES_USER: miniflux
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: miniflux
volumes:
- db_data:/var/lib/postgresql
networks:
- backend
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
proxy:
backend:
internal: true
volumes:
caddy_data:
caddy_config:
db_data:The parts that matter:
depends_onwithcondition: service_healthymakes Compose start the app only oncepg_isreadysays the database accepts connections. Without it the app often starts first, fails to connect and restarts a few times.${POSTGRES_PASSWORD:?...}stops Compose with an error if the variable is missing, instead of quietly using an empty password.restart: unless-stoppedbrings the containers back after a crash or a reboot, as long as the Docker service starts at boot (it does by default).- Named volumes (
db_data,caddy_data) live under/var/lib/docker/volumesand survivedocker compose downand image updates.caddy_dataholds the certificates; losing it means asking Let's Encrypt again, which has rate limits. - PostgreSQL 18 images store data in a versioned directory, so the volume is mounted at
/var/lib/postgresql, not/var/lib/postgresql/dataas in older guides.
Write the Caddyfile
Create /opt/miniflux/Caddyfile:
rss.example.com {
encode zstd gzip
reverse_proxy miniflux:8080
}miniflux:8080 is the service name and the port the app listens on inside its container; Docker's DNS resolves service names on a shared network. Caddy gets a certificate for the domain, renews it, and redirects HTTP to HTTPS without more settings.
To serve a second app from the same Caddy later, add it to the proxy network and give it its own block with its own domain. Caddy as a reverse proxy for several apps covers that in depth.
Start the stack
cd /opt/miniflux
sudo docker compose config --quiet
sudo docker compose up -dconfig --quiet prints nothing when the file is valid and the variables resolve. up -d pulls the images, creates the networks and volumes, starts the database, waits for it to be healthy, then starts the app and Caddy.
Check it worked
sudo docker compose ps
sudo docker compose logs caddy | grep -i certificate
curl -I https://rss.example.comps lists three services, db and miniflux with (healthy) after a minute. Caddy's log shows certificate obtained successfully. curl gets HTTP/2 200. Open https://rss.example.com and log in as admin with the password from .env.
Check that the database is really private. It publishes no port, and it has no route out:
sudo docker compose exec db sh -c 'wget -q -T 5 -O- http://example.com > /dev/null && echo online || echo offline'It prints offline. The app container, on both networks, can still fetch feeds.
Update the containers
To move to a new version, read the app's release notes, change the tag in compose.yaml (for example miniflux/miniflux:2.3.4 to the next release), then:
cd /opt/miniflux
sudo docker compose pull
sudo docker compose up -d
sudo docker image prune -fup -d recreates only the containers whose image or settings changed; the volumes stay. Back up first (next step), especially before a database major version.
Don't change the PostgreSQL major version (18 to 19) by editing the tag: the new server can't read the old data files. Dump the database, start a fresh volume on the new version, and restore the dump into it.
After any change to compose.yaml or .env, run docker compose up -d again; a plain restart keeps the old settings. After a change to the Caddyfile only, reload Caddy without downtime:
sudo docker compose exec caddy caddy reload --config /etc/caddy/CaddyfileBack up the volumes
Back up the database with a dump, not by copying its files while it runs. A dump is consistent and restores into any later PostgreSQL version:
cd /opt/miniflux
sudo docker compose exec -T db pg_dump -U miniflux -d miniflux --format=custom > /root/miniflux-$(date +%F).dumpOther volumes (here, Caddy's certificates) can be archived with a throwaway container:
sudo docker run --rm -v miniflux_caddy_data:/data -v /root:/backup alpine tar czf /backup/caddy-data-$(date +%F).tgz -C /data .Also keep a copy of compose.yaml, Caddyfile and .env: with those and the dump you can rebuild the whole stack on a new server.
To restore the dump into the running database:
sudo docker compose exec -T db pg_restore -U miniflux -d miniflux --clean --if-exists < /root/miniflux-2026-10-21.dumpCopy the files off the server every night. Back up your server shows how, and restic to S3-compatible storage is another way.
Troubleshooting
required variable POSTGRES_PASSWORD is missing a value. Compose can't find .env, or the variable isn't in it. Run Compose from /opt/miniflux (or pass --env-file), and check the spelling.
Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster". The volume is mounted at /var/lib/postgresql/data, as in guides for older versions. Mount it at /var/lib/postgresql. If the volume already holds data from an older major version, dump it with the old image first.
Caddy logs could not get certificate from issuer, and the browser shows a certificate error. DNS doesn't point at this server yet, or port 80 is blocked in ufw or a firewall in front of the server. Check getent hosts rss.example.com and sudo ufw status, then sudo docker compose restart caddy.
502 Bad Gateway from Caddy. Caddy can't reach the app: it is still starting, it crashed, or it isn't on the proxy network. sudo docker compose ps and sudo docker compose logs miniflux show which.
password authentication failed for user "miniflux". The database volume was created with an earlier password. PostgreSQL only reads POSTGRES_PASSWORD when it creates an empty data directory. Put the old password back in .env, or change it inside the database with ALTER USER miniflux PASSWORD '...'.
Next steps
- Add a status page and alerts for this app with Uptime Kuma.
- Self-host your Git repositories in the same way with Gitea.
- Lock down the rest of the server with ufw.
- Read the official Compose file reference and Caddy's reverse_proxy docs.