A Little Bit TechA Little Bit Tech
HOME LAB26 min read

Docker Compose: A Beginner-to-Expert Guide

Learn Docker Compose from your first compose.yaml to production-grade stacks: core elements, variables and secrets, healthchecks, profiles, overrides, tips and tricks, and how to harden services at the Compose level.

By Mark Poulton

A compose.yaml file defining a project with proxy, app and db services, frontend and internal backend networks, a db_data volume and a db_password secret, alongside the running project it creates: only the proxy is published on 127.0.0.1 port 443, and the database sits on the internal network onlyA compose.yaml file defining a project with proxy, app and db services, frontend and internal backend networks, a db_data volume and a db_password secret, alongside the running project it creates: only the proxy is published on 127.0.0.1 port 443, and the database sits on the internal network only

Docker Compose: A Beginner-to-Expert Guide

Docker Compose lets you describe a whole application, every container, network, volume and secret, in one YAML file called compose.yaml, then start, update and remove it with a single command. If you run more than one container, Compose is the difference between a server you remember how to rebuild and one you don't.

This guide is the third in the A Little Bit Tech Docker series. The weekend project to build a properly managed Docker host gets a server running with a first Compose file, and the ten best Docker containers for home users suggests what to run on it. This article goes deeper into Compose itself:

  • Beginner: what Compose is, why it beats docker run, the core elements of a Compose file and the commands you'll use every day.
  • Intermediate: variables, .env files, healthchecks, startup order, logging and resource limits.
  • Advanced: profiles, override files, include, YAML anchors, one-shot jobs and development watch mode.
  • Security: the Compose settings that make a stack meaningfully harder to attack, and the limits of what Compose can protect.

Each section stands on its own, so skip to the level you need. Examples were checked against the Compose Specification and Docker documentation in November 2026.

What is Docker Compose?

Docker Compose is a tool for defining and running multi-container applications. You write the desired state in a YAML file: which images to run, how they connect, which ports they publish and where their data lives. Compose then creates or updates containers, networks and volumes to match.

Three terms come up constantly:

  • Service: one component of your application, such as a web app or a database. A service runs as one or more containers created from the same configuration.
  • Project: everything defined by one Compose file (or set of files). Compose labels every resource with the project name, so it knows what belongs to the stack.
  • Compose Specification: the open standard that defines the file format. Docker Compose implements it, and so do other tools such as Podman Compose, to varying degrees.

Compose is declarative. You don't tell it "start this, then create that network". You describe the end state, and Compose works out what to change. Run docker compose up -d twice and the second run does nothing, because everything already matches the file.

Why use Docker Compose instead of docker run?

Short answer: a docker run command is something you type and forget. A Compose file is a record you can read, version, review and replay.

Here's the same container both ways. First with docker run:

docker run -d --name uptime-kuma --restart unless-stopped \
  -p 127.0.0.1:3001:3001 -v uptime-kuma-data:/app/data \
  louislam/uptime-kuma:2

And as a Compose service:

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2
    restart: unless-stopped
    ports:
      - "127.0.0.1:3001:3001"
    volumes:
      - uptime-kuma-data:/app/data

volumes:
  uptime-kuma-data:

The Compose version is longer, but it's the one you'll still understand in a year. The advantages grow with every service you add:

Concerndocker runDocker Compose
Record of the configurationShell history, if you're luckyA file you can commit to Git
Multiple containersOne command per container, in the right orderOne up for the whole stack, ordered by dependencies
NetworkingCreate networks by hand and attach each containerA private network per project, with DNS by service name
UpdatesStop, remove and re-run with the same flagspull then up -d recreates only what changed
Review and change controlHard to diff a commandPull requests and git diff on a YAML file
Rebuilding after a failureReconstruct every command from memoryRestore the file and volumes, then run up -d

Use docker run for throwaway experiments. For anything you expect to still be running next month, use Compose.

Which version of Compose should you use?

Use the Compose plugin that ships with current Docker Engine and Docker Desktop, invoked as docker compose (with a space). Check what you have:

docker compose version

A few version facts trip people up:

  • docker-compose (with a hyphen) is the old Python tool, Compose v1. It stopped receiving updates in 2023. If a guide uses docker-compose, the file usually still works, but run it with docker compose.
  • Compose jumped from 2.x to 5.x in late 2025. The project skipped 3 and 4 to avoid confusion with the old version: "3" file formats. The 5.x line also delegates image builds to Docker's Bake engine and makes Compose usable as a Go SDK. Everything in this article works on Compose 2.24 or later, including every 5.x release.
  • The top-level version: key is obsolete. Compose ignores it and prints a warning. Delete it from old files rather than worrying about which number to use.
  • Name the file compose.yaml. Compose also accepts compose.yml, docker-compose.yaml and docker-compose.yml, but compose.yaml is the preferred name.

What goes in a compose.yaml file?

A Compose file has a handful of top-level elements. Only services is required.

ElementWhat it definesWhen you need it
nameThe project name, used to label and prefix resourcesAlways worth setting, so it doesn't depend on the folder name
servicesThe containers to run and how to configure themAlways
networksCustom networks for services to joinWhen you want to separate services from each other
volumesNamed volumes for persistent dataWhenever a service stores data you want to keep
secretsSensitive values, mounted into containers as filesPasswords, API keys and certificates
configsNon-sensitive configuration files mounted into containersConfig files you'd rather not bind mount
includeOther Compose files to load as part of this projectSplitting a large stack into smaller files
x-*Extension fields that Compose ignoresReusable YAML snippets (covered later)

Here's a small but complete example: a web app with a PostgreSQL database.

name: notes

services:
  app:
    image: ghcr.io/example/notes:1.8.2
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DATABASE_HOST: db
    depends_on:
      db:
        condition: service_healthy
    networks:
      - web
      - data

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: notes
      POSTGRES_USER: notes
      POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5

networks:
  web:
  data:
    internal: true

volumes:
  db_data:

The ghcr.io/example/notes image is a placeholder. Swap in any app that talks to PostgreSQL. The rest of this article explains each part.

The core service elements

Most services use the same eight or so keys. Learn these and you can read almost any Compose file.

image: what to run

image names the container image, optionally with a registry, tag or digest:

image: postgres:17-alpine            # Docker Hub, tag 17-alpine
image: ghcr.io/home-assistant/home-assistant:stable   # GitHub Container Registry

If you omit the tag, Docker uses latest, which means "whatever is newest the next time this host pulls". That's fine for a first test, but pin a specific version once a service works. Updates then happen when you decide, and you can roll back by changing the tag. The security section covers pinning by digest as well.

To build from source instead, use build:

build:
  context: ./app
  dockerfile: Dockerfile

restart: what happens after a crash or reboot

PolicyBehaviour
noNever restart (the default)
alwaysAlways restart. Even a container you stopped manually comes back when the Docker daemon restarts
unless-stoppedRestart unless you stopped it yourself. The best default for long-running services
on-failure[:N]Restart only if the container exits with an error, optionally up to N times

Don't set a restart policy on one-shot jobs such as migrations or backups. Otherwise a job that exits normally may be started again and again.

ports: what the outside world can reach

ports publishes a container port on the host, written HOST:CONTAINER:

ports:
  - "8080:80"              # all host interfaces, port 8080 -> container port 80
  - "127.0.0.1:8080:80"    # only this machine
  - "192.168.1.50:53:53/udp"  # one interface, UDP

Two habits avoid most port problems:

  1. Quote port mappings. The Compose documentation recommends strings, because YAML can misread some unquoted xx:yy values as numbers.
  2. Include a host address. Without one, Docker publishes on every interface, including any that face the internet. More on this in the security section.

Containers on the same network don't need ports to talk to each other. Publish a port only when something outside Docker needs to connect.

volumes: where data survives

Containers are disposable: Compose deletes and recreates them on every image or configuration change. Anything you want to keep must be on a volume.

services:
  app:
    volumes:
      - app_data:/data                 # named volume, managed by Docker
      - ./config:/config               # bind mount, relative to compose.yaml
      - ./certs:/certs:ro              # bind mount, read-only

volumes:
  app_data:
  • Named volumes live under Docker's data directory (usually /var/lib/docker/volumes). Docker manages them, and they're the right choice for databases.
  • Bind mounts map a host folder or file into the container. They're easier to browse, edit and back up with ordinary tools, and they're the right choice for configuration files you want to edit.

A common surprise: if a bind-mount path doesn't exist, the short syntax creates it as an empty directory owned by root. If a container fails to start because its config file "is a directory", that's why. Create files before you mount them.

environment: settings for the application

environment sets environment variables inside the container. Most images are configured this way.

environment:
  TZ: Pacific/Auckland
  LOG_LEVEL: info

You can also load variables from a file with env_file. Don't confuse it with the .env file, which does something different. The variables section explains both.

networks: who can talk to whom

By default, Compose creates one network called <project>_default and attaches every service to it. Services find each other by service name: in the earlier example, the app connects to the host db, and Docker's internal DNS resolves it to the database container.

Define your own networks when some services shouldn't reach others:

networks:
  web:
  data:
    internal: true   # no route in or out of the host

A service joins only the networks it lists. In the example, the app joins web and data, but the database joins only data. Since data is internal, the database can't reach the internet and nothing outside Docker can reach it.

depends_on and healthcheck: start in the right order

depends_on controls start order. The short form only waits for a container to start, which isn't the same as being ready. A database container starts in a second but may take much longer to accept connections.

Combine a healthcheck with a condition to wait for readiness:

services:
  app:
    depends_on:
      db:
        condition: service_healthy
        restart: true          # restart app if Compose restarts db
  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

The three conditions are:

ConditionWaits until the dependency…
service_startedhas started (the default)
service_healthypasses its healthcheck
service_completed_successfullyhas run and exited with code 0, which is useful for migrations and setup jobs

Note the $$ in the healthcheck. Compose treats $ as the start of its own variable, so $$ passes a literal $ through to the container's shell.

The Docker Compose commands you'll actually use

Run these from the folder that contains compose.yaml, or point at it with -f.

CommandWhat it does
docker compose up -dCreate or update everything, in the background
docker compose up -d --waitAs above, but wait until services are running and healthy
docker compose psList the project's containers, their state and health
docker compose logs -f --tail=100 appFollow the last 100 log lines for one service
docker compose exec app shOpen a shell in the running app container
docker compose run --rm app <command>Run a one-off command in a new, temporary container
docker compose pullDownload newer images for the tags in the file
docker compose restart appRestart a container without applying file changes
docker compose stop / startStop or start containers without removing them
docker compose downRemove containers and networks. Named volumes are kept
docker compose down -vAlso delete named volumes. This deletes your data
docker compose configPrint the fully resolved file, after variables and merges
docker compose config --quietValidate the file and print nothing unless there's an error
docker compose lsList every Compose project on the host

Two distinctions matter:

  • restart doesn't apply changes. If you edit compose.yaml, run docker compose up -d. Compose recreates only the services whose configuration changed.
  • exec versus run. exec runs a command in the existing container. run starts a new container from the service definition, which is useful for one-off jobs. Add --rm so it's removed afterwards.

The safe update routine for most stacks is:

docker compose pull          # fetch new images
docker compose up -d         # recreate only what changed
docker image prune           # optionally, remove the old images

How do .env, env_file and environment differ?

This is the most common source of Compose confusion. There are three mechanisms, and they do different jobs.

How Compose handles variables: shell variables and the .env file feed interpolation, which fills in placeholders in compose.yaml to produce the resolved model, and the environment attribute then sets container environment variables; an env_file sets container environment variables directly, while a secret file is mounted into the container under /run/secrets.How Compose handles variables: shell variables and the .env file feed interpolation, which fills in placeholders in compose.yaml to produce the resolved model, and the environment attribute then sets container environment variables; an env_file sets container environment variables directly, while a secret file is mounted into the container under /run/secrets.

The .env file fills in the Compose file

The .env file in the project folder is read by Compose itself, not by your containers. Its values replace ${VARIABLE} placeholders in compose.yaml. This is called interpolation.

# .env
APP_IMAGE=ghcr.io/example/notes:1.8.2
BIND_ADDRESS=127.0.0.1
DB_PASSWORD=6f1c0e...
services:
  app:
    image: ${APP_IMAGE}
    ports:
      - "${BIND_ADDRESS:-127.0.0.1}:8080:8080"

A variable in .env doesn't reach the container unless the Compose file passes it through, for example in an environment entry. Variables exported in your shell take precedence over .env, which is handy for one-off overrides and confusing when you forget you exported something.

Interpolation supports defaults and required values:

SyntaxResult
${VAR}The value of VAR, or empty if unset
${VAR:-default}default if VAR is unset or empty
${VAR-default}default only if VAR is unset
${VAR:?message}Stop with message if VAR is unset or empty
${VAR:+other}other if VAR is set and non-empty, otherwise empty
$$A literal $

Use :? for anything the stack can't run without, such as passwords and image names. A clear error at up time beats a container that starts with an empty password.

To check the result, run docker compose config. It prints the file exactly as Compose sees it after interpolation. Be careful where you paste that output, because it includes the substituted values.

env_file and environment configure the container

environment and env_file both set variables inside the container:

services:
  app:
    env_file:
      - path: ./app.env
        required: true
      - path: ./app.local.env
        required: false      # skipped if the file doesn't exist
    environment:
      LOG_LEVEL: debug       # wins over the same key in app.env

If the same variable appears in several places, environment beats env_file, which beats any default set in the image. env_file is useful when an app has dozens of settings and you'd rather keep them out of the main file.

Secrets are files, not variables

Secrets take a third route: Compose mounts each one as a file under /run/secrets/. That's safer than an environment variable, for reasons covered in the security section.

Useful additional elements for intermediate users

Once a stack is running, these settings make it more robust and easier to operate.

Rotate logs before they fill the disk

Docker's default json-file log driver doesn't rotate logs unless you configure it, so a chatty container can slowly fill a disk. Set limits per service:

logging:
  driver: local
  options:
    max-size: "10m"
    max-file: "3"

The local driver is more compact than json-file and rotates by default. Alternatively, set a host-wide default in /etc/docker/daemon.json so every container inherits it. If you want logs off the host for searching and alerting, the guide to logs, metrics and traces covers what to collect.

Limit memory, CPU and processes

One misbehaving container shouldn't be able to take the whole host down:

mem_limit: 1g
cpus: 1.5
pids_limit: 200

mem_limit caps memory, and the kernel stops the container's processes if they exceed it. cpus caps CPU time, and pids_limit caps the number of processes, which stops a fork bomb or runaway worker pool. The longer deploy.resources.limits syntax works too. Set limits after watching normal usage for a few days, with headroom. A limit that's too tight causes crashes that look like application bugs.

Shut down cleanly

When Compose stops a container, it sends SIGTERM, waits 10 seconds, then forces it with SIGKILL. Databases and apps with queues may need longer:

stop_grace_period: 60s
init: true

init: true runs a tiny init process as PID 1 inside the container. It forwards signals and reaps zombie processes, which some images don't handle well on their own.

Use configs for small configuration files

configs mounts a file into the container, read-only. Since Compose 2.23.1 you can write the content inline, which keeps a short config file next to the service that uses it:

services:
  proxy:
    configs:
      - source: caddyfile
        target: /etc/caddy/Caddyfile

configs:
  caddyfile:
    content: |
      notes.home.example {
        reverse_proxy app:8080
      }

Add labels for the tools around you

Labels are metadata that other tools read. Reverse proxies such as Traefik discover routes from labels, and update tools use them to opt containers in or out:

labels:
  traefik.enable: "true"
  traefik.http.routers.notes.rule: "Host(`notes.home.example`)"

Anything that reads labels usually needs the Docker socket, so read the socket advice in the security section before adding one.

Advanced Compose techniques

Profiles: optional services in the same file

Assign a service to a profile and Compose skips it unless that profile is active. Services without a profile always start.

services:
  app:
    image: ghcr.io/example/notes:1.8.2

  adminer:
    image: adminer:5
    profiles: ["debug"]
    ports:
      - "127.0.0.1:8081:8080"

  backup:
    image: postgres:17-alpine
    profiles: ["backup"]
docker compose up -d                       # app only
docker compose --profile debug up -d       # app plus adminer
docker compose run --rm backup             # run the backup job on demand

Naming a service directly, as in the run command, starts it even if its profile isn't active. Profiles are ideal for debugging tools, admin UIs you don't want running all the time, and scheduled jobs.

Override files: one base, several environments

If a file called compose.override.yaml exists next to compose.yaml, Compose merges it automatically. Use it for machine-specific tweaks that you don't want in the shared file:

# compose.override.yaml (not committed)
services:
  app:
    environment:
      LOG_LEVEL: debug
    ports:
      - "127.0.0.1:9229:9229"   # debugger

For explicit layering, pass files with -f. Later files override earlier ones:

docker compose -f compose.yaml -f compose.prod.yaml up -d

Merging rules matter. Single values such as image are replaced, while lists such as ports are combined. That means an override can add a port but can't remove one. To clear or fully replace a value, use the !reset and !override YAML tags (Compose 2.24 and later):

services:
  app:
    ports: !reset []                       # remove all ports from the base file
    environment: !override
      LOG_LEVEL: warn                      # replace the whole map

Always check the merged result with docker compose config before you deploy it.

include: split a big stack into parts

include pulls other Compose files into the project, each resolved relative to its own folder:

# compose.yaml
include:
  - monitoring/compose.yaml
  - smart-home/compose.yaml

services:
  proxy:
    image: caddy:2

Unlike -f merging, include treats each file as a self-contained unit and reports an error if two files define the same service. It suits a home lab that has grown into separate areas maintained at different times.

Extension fields and YAML anchors: don't repeat yourself

Compose ignores any top-level key starting with x-. Combine that with YAML anchors (&name), aliases (*name) and merge keys (<<:) to define shared settings once:

x-defaults: &defaults
  restart: unless-stopped
  logging:
    driver: local
    options:
      max-size: "10m"
  security_opt:
    - no-new-privileges:true

services:
  app:
    <<: *defaults
    image: ghcr.io/example/notes:1.8.2

  worker:
    <<: *defaults
    image: ghcr.io/example/notes-worker:1.8.2

Keys you set on the service win over those from the anchor. A merge key copies a whole map but doesn't deep-merge nested maps, so if a service sets its own logging, it replaces the anchor's logging entirely. docker compose config shows the expanded result. extends offers a similar result across files, by copying a service definition from another file.

One-shot jobs: migrations and setup

Some tasks should run once before the main service starts, such as a database migration or fixing volume permissions. Model them as services that exit, and use service_completed_successfully:

services:
  migrate:
    image: ghcr.io/example/notes:1.8.2
    command: ["notes", "migrate"]
    depends_on:
      db:
        condition: service_healthy

  app:
    image: ghcr.io/example/notes:1.8.2
    depends_on:
      migrate:
        condition: service_completed_successfully

If the migration fails, the app doesn't start, which is exactly what you want. Recent 5.x releases have also started adding native init-container and job support. Check the release notes for your version before relying on it, because these features are new and still evolving.

Watch mode for development

If you build your own images, develop.watch syncs code into a running container or rebuilds it when files change:

services:
  web:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json

Start it with docker compose watch. This is a development convenience, not something to run on a home server.

How do you secure a Docker Compose stack?

Compose can't make containers as isolated as virtual machines, because containers share the host's kernel. What it can do is apply least privilege: give each service only the network access, permissions and resources it needs, so a compromised or buggy container can do less damage.

Compose security model: traffic from the LAN or internet reaches only a reverse proxy on port 443; the proxy and app share a frontend network; the app and database share an internal backend network with no route out; each service has hardening such as a read-only filesystem, no-new-privileges, a non-root user, dropped capabilities, secrets instead of environment variables, no published database port and resource limits; and the Docker socket is not mounted into any application.Compose security model: traffic from the LAN or internet reaches only a reverse proxy on port 443; the proxy and app share a frontend network; the app and database share an internal backend network with no route out; each service has hardening such as a read-only filesystem, no-new-privileges, a non-root user, dropped capabilities, secrets instead of environment variables, no published database port and resource limits; and the Docker socket is not mounted into any application.

Work through the controls below in roughly this order. The first few cost nothing and break nothing. The later ones need testing per image.

1. Publish as few ports as possible, on the right address

Every published port is a way in. Publish only what people need to reach, ideally just a reverse proxy, and bind admin interfaces to 127.0.0.1:

ports:
  - "127.0.0.1:8080:8080"      # reachable from this host only

Be aware that Docker's published ports bypass host firewalls such as UFW. Docker writes its own packet-filtering rules, so a port published on all interfaces can be reachable even when UFW says it's blocked. Binding to a specific address in Compose is the reliable control. Never forward admin ports on your router. Use a VPN such as WireGuard or Tailscale for remote access instead.

2. Segment networks and give back-end services no way out

Put databases and other back-end services on an internal: true network with no published ports. They then can't be reached from outside, and if they're compromised they can't connect out to download tools or send data. Avoid network_mode: host unless a service genuinely needs it, such as Home Assistant's device discovery, because it removes network isolation entirely.

3. Use secrets instead of environment variables for passwords

Environment variables are easy to leak. Anyone who can run docker inspect sees them, every process in the container inherits them, and they tend to end up in crash reports and debug logs. A Compose secret is mounted as a file at /run/secrets/<name> instead:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Many official images support this pattern: a variable ending in _FILE tells the app to read the value from a file. PostgreSQL, MariaDB and MySQL images all do.

Get the file permissions right. For file-based secrets, Compose uses a bind mount, so the container sees the host file's ownership and mode. The uid, gid and mode options in the Compose file are ignored for this source type. The container's user must be able to read the file, so a host file with mode 600 owned by your login may be unreadable to a database running as its own user. A practical pattern is to lock down the folder and leave the file readable:

mkdir -m 700 secrets
openssl rand -hex 24 > secrets/db_password.txt
chmod 644 secrets/db_password.txt
echo "secrets/" >> .gitignore

Other users on the host can't get into the 700 folder, while the container reads the file through its mount. Secrets can also come from a variable (environment: DB_PASSWORD under the secret definition), which keeps the value in your .env file but still delivers it to the container as a file.

4. Run as a non-root user

Many images run as root inside the container by default. If an attacker escapes the application, root inside the container is a much better starting point for attacking the host than an ordinary user.

user: "1000:1000"

This works only if the image supports it: the app must not need root to start, and the user must be able to write to its volumes. Many images instead offer a variable such as PUID/PGID (LinuxServer.io images) or switch to their own user after startup (PostgreSQL does this). Check the image documentation first, and prefer images that run unprivileged by design.

5. Drop Linux capabilities and block privilege escalation

Docker gives containers a default set of Linux capabilities, such as changing file ownership, binding to low ports and sending raw network packets. Most applications need none of them:

cap_drop:
  - ALL
cap_add:
  - NET_BIND_SERVICE     # only if it must listen on a port below 1024
security_opt:
  - no-new-privileges:true

no-new-privileges stops processes from gaining privileges through setuid binaries, and it's safe for almost every container, so it belongs in your shared defaults. cap_drop: [ALL] is stronger but more likely to break images that start as root and then change file ownership or switch user. Add capabilities back one at a time, guided by the errors in the logs.

6. Make the filesystem read-only

A read-only root filesystem stops an attacker, or a buggy update, from modifying the application's files. Give the app writable tmpfs mounts for the few places it needs:

read_only: true
tmpfs:
  - /tmp

If the container fails to start, its log normally names the path it couldn't write to. Add a tmpfs or a volume for that path and try again.

7. Set resource limits

mem_limit, cpus and pids_limit are reliability settings, but they're security settings too: they limit what a compromised container can do to its neighbours. Set at least pids_limit and mem_limit on anything exposed to the network.

8. Keep the Docker socket out of applications

Mounting /var/run/docker.sock gives a container full control of Docker, which means root on the host. Adding :ro doesn't help: it stops the socket file being changed, not the API calls made through it.

Only mount the socket into tools whose whole purpose is managing Docker, such as Portainer or Dockhand, and treat their logins as admin passwords. For tools that only need to read container information, such as dashboards, reverse proxies and update notifiers, put a socket proxy in between. A proxy such as Tecnativa's docker-socket-proxy or the LinuxServer.io socket-proxy exposes only the API endpoints you enable, on an internal network.

9. Avoid privileged mode and pass through only the devices you need

privileged: true turns off almost every isolation feature Docker has. If a guide tells you to use it, look for a narrower option first. Usually that's a specific devices entry, such as a Zigbee adapter or a GPU, plus one or two capabilities:

devices:
  - /dev/serial/by-id/usb-YOUR_ADAPTER_ID:/dev/ttyACM0

10. Pin images and know where they come from

A tag like postgres:17 can point at a different image tomorrow. For critical services, pin the exact image digest as well as the tag:

image: postgres:17-alpine@sha256:<digest>

When a digest is present, Docker uses it and the tag becomes a readable label. Get the digest with docker image inspect --format '{{index .RepoDigests 0}}' postgres:17-alpine. Tools such as Renovate can open pull requests to update tags and digests, so pinning doesn't mean falling behind on security fixes.

Prefer Docker Official Images, verified publishers and well-maintained community sources such as LinuxServer.io, and scan images for known vulnerabilities with docker scout cves or Trivy before you trust them.

A hardened service template

Here's what those controls look like together for a simple web service. It uses NGINX's unprivileged image, which runs as a non-root user on port 8080 by design:

services:
  web:
    image: nginxinc/nginx-unprivileged:1.28-alpine
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    mem_limit: 256m
    pids_limit: 100
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./site:/usr/share/nginx/html:ro
    networks:
      - web
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  web:

Apply hardening one setting at a time on an existing stack, running docker compose up -d and checking the logs after each change. If you apply everything at once and it breaks, you won't know which setting caused it.

What Compose can't secure

Compose settings operate within the container. Some protections live elsewhere:

  • The Docker daemon itself. Rootless Docker, or user-namespace remapping, runs containers so that root inside a container isn't root on the host. These are daemon-level choices, not Compose settings.
  • The host. Kernel updates, SSH hardening and disk encryption matter more than any single container setting, because every container shares the kernel.
  • The application. A hardened container running an unpatched app with a default password is still a risk. Update images regularly and change default credentials.
  • Who can run Docker. Membership of the docker group is equivalent to root. Keep it to admin accounts.

Putting it together: a production-grade reference stack

The Docker host weekend project runs Wiki.js with PostgreSQL. Here's how that stack looks with the techniques from this article applied: shared defaults through an anchor, file-based secrets, an internal database network, log rotation, resource limits, and an on-demand backup job behind a profile.

name: wiki

x-hardened: &hardened
  security_opt:
    - no-new-privileges:true
  logging:
    driver: local
    options:
      max-size: "10m"
      max-file: "3"

services:
  wiki:
    <<: *hardened
    image: ${WIKI_IMAGE:?Set WIKI_IMAGE in .env}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
        restart: true
    environment:
      DB_TYPE: postgres
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USER: wiki
      DB_NAME: wiki
      DB_PASS_FILE: /run/secrets/db_password
    secrets:
      - db_password
    ports:
      - "${BIND_ADDRESS:-127.0.0.1}:8080:3000"
    networks:
      - web
      - database
    mem_limit: 1g
    pids_limit: 200

  postgres:
    <<: *hardened
    image: ${POSTGRES_IMAGE:?Set POSTGRES_IMAGE in .env}
    restart: unless-stopped
    environment:
      POSTGRES_DB: wiki
      POSTGRES_USER: wiki
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - wiki_db_data:/var/lib/postgresql/data
    networks:
      - database
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    mem_limit: 1g
    pids_limit: 200

  # On demand: docker compose run --rm backup
  backup:
    <<: *hardened
    image: ${POSTGRES_IMAGE:?Set POSTGRES_IMAGE in .env}
    profiles: ["backup"]
    user: "1000:1000"            # your host user, so dump files belong to you
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      PGHOST: postgres
      PGUSER: wiki
      PGDATABASE: wiki
    secrets:
      - db_password
    volumes:
      - ./backups:/backups
    networks:
      - database
    entrypoint: ["sh", "-c"]
    command:
      - PGPASSWORD="$$(cat /run/secrets/db_password)" pg_dump -Fc -f /backups/wiki-$$(date +%F).dump

volumes:
  wiki_db_data:

networks:
  web:
  database:
    internal: true

secrets:
  db_password:
    file: ./secrets/db_password.txt

The decisions worth noticing:

  • restart isn't in the shared anchor. The backup job must exit when it's done, so only long-running services get a restart policy.
  • The password never appears as an environment variable. Wiki.js reads DB_PASS_FILE, PostgreSQL reads POSTGRES_PASSWORD_FILE, and the backup job reads the file only for the lifetime of pg_dump. Note the $$, which stops Compose from trying to interpolate the shell commands.
  • The database can't be reached from outside, and can't reach out. It joins only the internal database network and publishes no ports.
  • Backups are a profile, not a permanently running container. Schedule docker compose run --rm backup from cron or a systemd timer, and keep testing restores as the weekend project describes.

If you're converting an existing stack, one caution: PostgreSQL's password variables only take effect when the database is first created. Put the current password in secrets/db_password.txt, or the existing database will reject it. The same applies to most database images.

Treat this as a pattern, not a drop-in file. Check each image's documentation for _FILE support and non-root behaviour, and confirm the image tags you pin in .env.

Docker Compose tips and tricks

Small habits that save time:

  • Set name: in every file. Otherwise the project name comes from the folder name, and renaming the folder makes Compose think it's a new project, leaving the old containers orphaned.
  • Use docker compose up -d --remove-orphans after removing a service from the file, so its old container doesn't keep running.
  • List what's defined with docker compose config --services, --volumes or --images.
  • Set COMPOSE_FILE and COMPOSE_PROFILES in .env if you always use the same file list or profiles, so you don't have to type -f and --profile every time.
  • Copy files in and out with docker compose cp app:/data/export.json ./.
  • Watch what's happening with docker compose events and docker compose top.
  • Keep one project per folder, with compose.yaml, .env.example and a .gitignore that excludes .env and secrets/. Commit the rest to Git.
  • Comment your decisions. A line saying why a service uses host networking or an extra capability is worth more than any amount of documentation elsewhere.
  • Validate in CI or a pre-commit hook with docker compose config --quiet, which catches syntax and interpolation errors before they reach the server.
  • Monitor health, not just uptime. docker compose ps shows healthcheck status, and tools such as Uptime Kuma can alert on it. Keep alerts to things someone must act on, as covered in designing actionable alerts.

Common Docker Compose mistakes and how to fix them

SymptomLikely causeFix
App can't connect to the database at startupdepends_on waits for start, not readinessAdd a healthcheck and condition: service_healthy
Data disappeared after an updateData was written inside the container, not a volumeMount a volume at the app's data path, then restore from backup
Data disappeared after downdown -v deleted the named volumesUse plain down, and restore from backup
"the attribute version is obsolete" warningOld file format headerDelete the version: line
Variable is empty inside the containerIt's in .env but never passed to the serviceReference it under environment, or use env_file
Healthcheck or command sees an empty $VARCompose interpolated it before the container saw itEscape it as $$VAR
Port reachable even though UFW blocks itDocker's published ports bypass UFWBind to 127.0.0.1 or a specific interface
Override file can't remove a portLists are merged, not replacedUse ports: !reset [] in the override
Config file mounted as an empty directoryThe host file didn't exist when the container startedCreate the file first, then recreate the container
Database rejects the password after a changePassword variables only apply on first initialisationChange the password in the database, then update the secret
Container won't start after adding read_onlyThe app writes somewhere you didn't provideRead the log for the path, then add a tmpfs or volume

The takeaway

Docker Compose turns a pile of containers into a system you can describe, review and rebuild. The core is small: images, restart policies, ports, volumes, environment, networks, and healthchecks for startup order. Learn those and you can run almost anything.

The advanced features earn their place as a stack grows. Profiles keep occasional tools out of the way, overrides and include keep files manageable, and anchors stop you repeating the same ten lines in every service.

Security at the Compose level is mostly about saying no by default: publish one port, bind it to the right address, keep databases on internal networks, use secrets rather than environment variables, and grant capabilities, write access and the Docker socket only to services that genuinely need them. None of these settings is hard on its own. Applying them one at a time, and testing as you go, is what makes a stack both secure and still working.

If you haven't built your Docker host yet, start with the weekend project. If it's running and you're looking for what to add next, the ten best Docker containers for home users is a good place to start.

References