# 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.

Source: https://vpsnine.com/tutorials/docker-compose-reverse-proxy · Updated: 2026-10-21

Difficulty: intermediate · Time: about 30 minutes · Tested on: Ubuntu 24.04 LTS

Prerequisites:

- 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](https://miniflux.app), 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](https://vpsnine.com/help/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](https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-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](https://vpsnine.com/help/backups-you-should-run) shows how, and [restic to S3-compatible storage](https://vpsnine.com/tutorials/restic-backup-s3) 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](https://vpsnine.com/tutorials/self-host-uptime-kuma).
- Self-host your Git repositories in the same way with [Gitea](https://vpsnine.com/tutorials/self-host-gitea).
- Lock down the rest of the server with [ufw](https://vpsnine.com/help/firewall-with-ufw).
- Read the official [Compose file reference](https://docs.docker.com/reference/compose-file/) and [Caddy's reverse_proxy docs](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy).
