Jump to: the compose.yaml, everyday commands, updating or troubleshooting.
Before you start
You need Docker Engine with the Compose plugin. If docker compose version prints a version number, you're ready. If it says 'compose' is not a docker command, install the docker-compose-plugin package from Docker's repository as described in the Docker Installation Guide.
docker compose version # Docker Compose version v2.x or later
docker info --format '{{.ServerVersion}}'
Everything here uses Compose V2, the docker compose subcommand with a space. The older Python tool, docker-compose with a hyphen, stopped receiving updates in 2023. Most files written for it still work, but the commands and some error messages differ, so don't mix the two on one host.
The examples run Miniflux, a small RSS reader, with a PostgreSQL database. It's a realistic pair: an application that can't start until its database accepts connections, a secret that both containers need, and data that has to survive upgrades. The same pattern fits most self-hosted apps.
How Compose thinks about a project
A Compose project is a directory with a compose.yaml in it. The project name defaults to the directory name, and Compose prefixes everything it creates with that name: containers, the default network and named volumes. Two copies of the same file in two directories are two independent projects, which is useful for running a test copy next to the real one.
- Services describe containers: the image, its settings and how many copies to run.
- Networks connect services. Every project gets a network called
project_default, and each service can reach the others by service name on it. - Volumes hold data that must outlive containers. Compose recreates containers freely when you change settings or images; anything not in a volume or bind mount is thrown away.
That last point is the one that catches people. Treat containers as disposable and decide up front where each service keeps its state.
Project layout
mkdir -p ~/stacks/miniflux && cd ~/stacks/miniflux
touch compose.yaml .env
chmod 600 .env
Keeping each stack in its own directory under one parent, such as ~/stacks or /opt/stacks, makes backups and upgrades predictable: one directory, one project, one set of files to copy.
The .env file
Compose reads .env from the project directory automatically and uses it to fill in ${VARIABLE} references in compose.yaml. Put values here that differ between machines or that shouldn't be committed to a repository.
MINIFLUX_TAG=2.3.4
POSTGRES_TAG=17-alpine
POSTGRES_PASSWORD=replace-with-a-long-random-string
MINIFLUX_ADMIN_USER=admin
MINIFLUX_ADMIN_PASSWORD=replace-with-another-long-random-string
Generate the passwords rather than inventing them, for example with openssl rand -base64 30. Stick to letters and digits if you can; a $, quote or # in a value needs quoting and is a common source of "the password is wrong" problems.
Three different mechanisms are easy to confuse:
| Mechanism | What it does |
|---|---|
.env in the project directory | Substitutes values into compose.yaml itself. The containers never see the file. |
environment: on a service | Sets environment variables inside that container. |
env_file: on a service | Loads every line of a file into that container's environment. |
The file below uses .env for substitution and passes only the variables each container needs through environment:, so the database never sees the Miniflux admin password.
The compose.yaml
name: miniflux
services:
db:
image: postgres:${POSTGRES_TAG}
restart: unless-stopped
environment:
POSTGRES_USER: miniflux
POSTGRES_DB: miniflux
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
miniflux:
image: miniflux/miniflux:${MINIFLUX_TAG}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8080:8080"
environment:
DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
RUN_MIGRATIONS: "1"
CREATE_ADMIN: "1"
ADMIN_USERNAME: ${MINIFLUX_ADMIN_USER}
ADMIN_PASSWORD: ${MINIFLUX_ADMIN_PASSWORD:?set MINIFLUX_ADMIN_PASSWORD in .env}
volumes:
db-data:
Line by line, the parts that matter:
- No
version:key. The Compose Specification doesn't use it, and current releases print a warning that it's obsolete. Delete it from older files. name: minifluxfixes the project name, so renaming the directory later doesn't orphan the volume. Without it, moving the directory creates a fresh, emptydb-datavolume under a new name.- Image tags come from
.env.postgres:17-alpinefollows PostgreSQL 17 patch releases but never jumps to 18 on its own, andminiflux/miniflux:2.3.4is an exact release (Miniflux publishes no floating2tag, so check its releases page for the current version). That matters because a new PostgreSQL major version can't open the old version's data directory; see Updating. ${VAR:?message}makes Compose stop with your message when the variable is empty, instead of starting a database with a blank password.restart: unless-stoppedbrings containers back after a crash or reboot, but respects a deliberatedocker compose stop.- The port is bound to
127.0.0.1. Only a reverse proxy on the same host can reach it. Plain"8080:8080"listens on every interface, and Docker's own firewall rules mean ufw or firewalld won't block it. The Docker Networking Explained guide covers why. - The database has no
ports:at all. Miniflux reaches it asdbon the project network. Nothing outside the project needs to.
Healthchecks and startup order
A plain depends_on: [db] only waits until the database container has started, which is a second or two before PostgreSQL is ready for connections. Applications that try to connect immediately then crash and restart a few times before things settle. The long form used above, condition: service_healthy, waits for the healthcheck to pass.
The healthcheck runs pg_isready inside the database container every 10 seconds. start_period gives PostgreSQL 30 seconds to initialise a new data directory before failed checks count against retries. You can watch it from the host:
docker compose ps # STATUS shows (health: starting) and then (healthy)
docker inspect --format '{{json .State.Health}}' miniflux-db-1
Two limits are worth knowing. The condition is only checked when Compose starts the stack; if the database restarts later, the app isn't stopped and restarted with it, so the app still needs to retry its own connections. And the check command has to exist in the image. pg_isready ships with the PostgreSQL image, but many minimal images have no curl or wget, so copy healthchecks from the image's own documentation where you can.
Check the file before you start it
docker compose config parses the file, fills in every variable from .env and prints the result. It's the quickest way to catch an indentation mistake or a variable that didn't resolve. It also prints your secrets, so don't paste its output anywhere public.
docker compose config --quiet && echo "compose.yaml is valid"
docker compose config | less
Start the stack and confirm it works
docker compose up -d
docker compose ps
docker compose logs -f miniflux # Ctrl+C stops following; the containers keep running
On the first start, PostgreSQL initialises the volume, the healthcheck turns healthy, and Miniflux runs its migrations and creates the admin account. Then check it from the host:
curl -sI http://127.0.0.1:8080/healthcheck # expect HTTP/1.1 200 OK
Once you've logged in with the admin account, remove CREATE_ADMIN, ADMIN_USERNAME and ADMIN_PASSWORD from the file and run docker compose up -d again. Compose notices the changed environment and recreates only the Miniflux container. The database keeps running.
Everyday commands
Run these from the project directory, or add -p miniflux to run them from anywhere.
| Command | What it does |
|---|---|
docker compose up -d | Creates or updates containers to match the file. Safe to run repeatedly; unchanged services are left alone. |
docker compose ps | Lists the project's containers, their state and health. |
docker compose logs -f --tail 100 miniflux | Follows one service's log, starting from the last 100 lines. |
docker compose exec db psql -U miniflux | Runs a command inside a running container. |
docker compose restart miniflux | Restarts a container without recreating it. Environment changes in the file are not applied; use up -d for that. |
docker compose stop / start | Stops and starts containers without removing them. |
docker compose down | Stops and removes containers and the project network. Named volumes are kept. |
docker compose pull | Downloads newer images for the tags in the file. |
Updating images
Updating is pull, then recreate:
docker compose pull
docker compose up -d
docker image prune # removes the old, now-unused image layers; asks first
Before you pull, read the release notes for anything with a database or a migration step. Moving within a tag such as postgres:17-alpine is routine. Changing the tag to 18-alpine isn't: the new server refuses to start on a data directory created by version 17. A PostgreSQL major upgrade means dumping the database, starting the new version on an empty volume and restoring the dump. The Docker Volumes and Backups guide shows the dump and restore commands.
For the same reason, avoid :latest on stateful services. It tells you nothing about what you're running, and a pull can quietly move you across a major version.
Override files and profiles
If a file named compose.override.yaml sits next to compose.yaml, Compose merges it in automatically. That's a clean way to keep machine-specific changes out of the main file. On a laptop, for example, you might expose the database to a desktop client:
services:
db:
ports:
- "127.0.0.1:5432:5432"
Lists such as ports are merged, while single values such as image are replaced. Run docker compose config to see the merged result. To use different files explicitly, pass them in order with -f compose.yaml -f compose.prod.yaml.
Profiles keep optional services in the same file without starting them by default. A one-off database admin tool is a good candidate:
services:
adminer:
image: adminer:4
profiles: ["tools"]
ports:
- "127.0.0.1:8081:8080"
docker compose up -d ignores it. docker compose --profile tools up -d starts it, and docker compose --profile tools stop adminer stops it again.
Troubleshooting
The "POSTGRES_PASSWORD" variable is not set
Compose couldn't find the variable. Check that you're in the project directory, that the file is named exactly .env, and that the line has no spaces around the =. Variables already exported in your shell take priority over .env, so env | grep POSTGRES is worth a look too.
Bind for 127.0.0.1:8080 failed: port is already allocated
Something else holds that port. sudo ss -ltnp | grep 8080 names the process. Change the left-hand number in ports:; the container's own port stays the same.
A container keeps restarting
docker compose ps shows Restarting, and docker compose logs --tail 50 miniflux shows why. Authentication errors right after you changed POSTGRES_PASSWORD are expected. The PostgreSQL image only reads that variable when it initialises an empty volume, so changing it later does nothing to the existing database. Either change the password inside PostgreSQL with ALTER USER, or, on a stack with no data you care about, remove the volume and start again.
Found orphan containers
You removed or renamed a service, and its old container is still running. docker compose up -d --remove-orphans cleans it up.
Changes to compose.yaml don't take effect
restart reuses the existing container. Run docker compose up -d, which recreates any container whose configuration changed.
Removing the stack
docker compose down # containers and network go; the db-data volume stays
docker compose down -v # also deletes named volumes: the database is gone for good
-v has no confirmation prompt. Take a database dump first if there's any chance you'll want the data back.
Next steps
- Docker Volumes and Backups: back up the
db-datavolume and test a restore. - Docker Networking Explained: separate frontend and backend networks and put a reverse proxy in front.
- Hardening Docker Containers: drop capabilities and add resource limits to these services.
- The Compose file reference lists every key used here and many more.