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:

bash
getent hosts rss.example.com

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

bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp

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

bash
sudo mkdir -p /opt/miniflux
cd /opt/miniflux

Write the settings and passwords to .env. Compose reads it automatically and substitutes the values into compose.yaml:

bash
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 .env

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

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_on with condition: service_healthy makes Compose start the app only once pg_isready says 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-stopped brings 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/volumes and survive docker compose down and image updates. caddy_data holds 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/data as in older guides.

Write the Caddyfile

Create /opt/miniflux/Caddyfile:

text
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

bash
cd /opt/miniflux
sudo docker compose config --quiet
sudo docker compose up -d

config --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

bash
sudo docker compose ps
sudo docker compose logs caddy | grep -i certificate
curl -I https://rss.example.com

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

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

bash
cd /opt/miniflux
sudo docker compose pull
sudo docker compose up -d
sudo docker image prune -f

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

bash
sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

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

bash
cd /opt/miniflux
sudo docker compose exec -T db pg_dump -U miniflux -d miniflux --format=custom > /root/miniflux-$(date +%F).dump

Other volumes (here, Caddy's certificates) can be archived with a throwaway container:

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

bash
sudo docker compose exec -T db pg_restore -U miniflux -d miniflux --clean --if-exists < /root/miniflux-2026-10-21.dump

Copy 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

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee