Docker Compose Example 2026: A Stack That Actually Works — With Our Own Measurements

Docker Compose Example 2026: A Stack That Actually Works — With Our Own Measurements

A Docker Compose example is never hard to find. The problem is not availability — it is that almost none of those examples were ever measured on a real server. They look plausible, they even start, and then the application does something different from what the tutorial promised.

So this Docker Compose example is built differently: every single claim below was measured on 19 September 2026 on a production Linux server (Docker 29.2.1, Docker Compose v5.0.2, Ubuntu with ufw active). Where a measurement contradicted our expectation, the measurement is what made it into the text — not the expectation.

The most important result first, because it is the only one that can cause real damage:

A port written as "8412:80" was reachable with HTTP 200 from a foreign server in Helsinki — while our firewall was active and had never opened port 8412. Written as "127.0.0.1:8412:80", the same port was closed from the outside.

This is not a Docker bug, and not a misconfiguration in the narrow sense. It is documented Docker behaviour — and it appears in almost no Compose example, even though practically every example uses the short, open form.

A futuristic corridor of teal glass panels receding towards a brightly glowing tunnel

The minimal Docker Compose example

Let us start with the smallest stack that actually does something. Create compose.yaml:

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "127.0.0.1:8411:80"

Start it:

docker compose up -d

On our machine that took 3.99 seconds, including the image check. Then:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8411/
# 200

That is all. No version: key, no build:, no eighty lines of boilerplate. If a Docker Compose example opens with a version: "3.8" line, it is out of date — more on that below.

The three commands that cover 90% of your usage

docker compose up -d        # start, detached
docker compose ps           # what is running right now?
docker compose logs -f web  # follow the logs of one service
docker compose down         # stop and remove everything

docker compose ps gave us:

NAME             PORTS
minimal-web-1    127.0.0.1:8411->80/tcp

Remember that output. It is the heart of the first major finding.

docker compose or docker-compose? The answer is unambiguous

This is the most common confusion for beginners, because both spellings appear in tutorials — with and without the hyphen.

  • docker-compose (hyphen) was version 1, written in Python, installed as a separate program. It is end of life and receives no updates.
  • docker compose (space) is version 2+, written in Go, shipped as a plugin inside Docker itself.

Measured on our system:

docker compose version
# Docker Compose version v5.0.2

which docker-compose
# not installed

That is the normal state on current installations: the old variant is simply gone. If a tutorial tells you to run docker-compose up and the command does not exist, your installation is not broken — the tutorial is old.

The version: line is not merely redundant, it is actively flagged

We tried it and added a version: "3.8" line to a working file:

level=warning msg="the attribute `version` is obsolete, it will be ignored,
please remove it to avoid potential confusion"

Compose says it itself: obsolete, will be ignored. The key predates the Compose Specification and has no effect today. Leave it out.

The filename changed too: the specification prefers compose.yaml. docker-compose.yml still works, but it is the older convention.

Finding 1: Your port mapping can bypass the firewall

This is the part where a pretty Docker Compose example can do real damage. Nearly every example online writes ports like this:

ports:
  - "8412:80"   # ⚠️ open to the entire internet

It looks harmless. What it means is: listen on all network interfaces, not just locally. We started two containers — one with the short form, one with the explicit form — and then measured from the outside.

First the local view on the server itself:

ss -tlnp | grep 8412
# LISTEN 0 4096 0.0.0.0:8412 0.0.0.0:* users:(("docker-proxy",...))
# LISTEN 0 4096    [::]:8412    [::]:* users:(("docker-proxy",...))

ss -tlnp | grep 8411
# LISTEN 0 4096 127.0.0.1:8411 0.0.0.0:* users:(("docker-proxy",...))

And now the part you must not measure on your own server. A curl against your own public IP travels over the loopback interface and never passes through the firewall chain — the result would be worthless. So we measured from a different server in Helsinki:

# executed on a foreign host, target = our server
port 8411: closed/filtered
port 8412: OPEN
HTTP 8412 -> 200

Port 8412 served HTTP 200 to a complete stranger on the internet. Meanwhile ufw was active the whole time, and 8412 appeared in no rule whatsoever.

The reason lies in the order of the firewall chains. Docker writes its rules into its own DOCKER chain, which is evaluated in the FORWARD path before the ufw rules:

iptables -L FORWARD -n | head -4
# Chain FORWARD (policy DROP)
# DOCKER-USER      0 -- 0.0.0.0/0  0.0.0.0/0
# DOCKER-FORWARD   0 -- 0.0.0.0/0  0.0.0.0/0
# ufw-before-forward ...

ufw says “denied”, Docker has already said “allowed”. There is no error, no warning, no log entry. It simply works — for everyone.

The rule: write every port explicitly bound to 127.0.0.1 unless the service is provably meant to be public.

ports:
  - "127.0.0.1:8412:80"   # ✅ local only, reverse proxy in front

We do not enforce this out of pedantry. We received a notice from the German federal cyber security agency because a PostgreSQL instance was exposed through exactly this short form. The pattern is always identical: an example copied from the web, started, it worked — and nobody ever looked from the outside.

If a service genuinely needs to be public, put a reverse proxy in front of it that handles TLS and access rules. We covered what that looks like in our nginx reverse proxy article; the underlying concept is explained in What is a reverse proxy?.

Finding 2: depends_on does not wait for what you think

The second big misunderstanding. A typical Docker Compose example for an app plus database looks like this:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: lab
      POSTGRES_DB: lab
  app:
    image: postgres:16-alpine
    depends_on:
      - db          # ⚠️ waits only for the container to start
    command: >
      sh -c "psql -h db -U postgres -d lab -c 'select 1'
             && echo CONNECT_OK || echo CONNECT_FAILED"
    environment:
      PGPASSWORD: lab

It looks entirely correct. Running it gave us:

app-1  | psql: error: connection to server at "db" (172.23.0.2), port 5432 failed:
         Connection refused
app-1  | CONNECT_FAILED

depends_on in this short form only guarantees the container start order, not the readiness of the service inside it. To show how wide that window really is, we timed both moments separately:

EventTime after up
Docker reports container state running0.02 s
Postgres actually accepts connections1.21 s
Gap where “running” and “ready” diverge1.19 s
Measured 19/09/2026, postgres:16-alpine, Docker 29.2.1. .State.Running queried via docker inspect, readiness polled with pg_isready at 0.1 s resolution.

Docker reports success 60 times before the database is ready. That is why such stacks work on a fast developer laptop and fall over on a slower server.

The fix: healthcheck plus condition: service_healthy

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: lab
      POSTGRES_DB: lab
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d lab"]
      interval: 2s
      timeout: 3s
      retries: 15
      start_period: 3s
  app:
    image: postgres:16-alpine
    depends_on:
      db:
        condition: service_healthy   # ✅ waits for readiness
    command: >
      sh -c "psql -h db -U postgres -d lab -c 'select 1' >/dev/null
             && echo CONNECT_OK || echo CONNECT_FAILED"
    environment:
      PGPASSWORD: lab

Control run, identical stack, identical images:

app-1  | CONNECT_OK

Connection refused became CONNECT_OK, in 6.78 seconds total runtime. The difference is seven lines of YAML.

Two luminous arched portals against a dark blue background, the left framing crashing waves, the right radiating a calm warm gradient

Finding 3: interval determines your wait — not the database

This is the finding we did not expect ourselves, and it affects nearly every Compose example online, because interval: 30s circulates there as the default.

We started the same stack three times and changed only the interval value:

intervalApp started after
2s3.58 s
10s11.68 s
30s31.73 s
Measured 19/09/2026, identical stack, reset with down -v before each run, timed from up until the dependent container exited.

Postgres was ready after 1.21 seconds in all three cases. The remaining thirty seconds are not spent waiting for the database — they are spent waiting for your own configuration. The first health check only runs after a full interval has elapsed.

We inspected the state mid-wait to make sure it was not a hang:

docker inspect -f '{{json .State.Health}}' $CID
# Status: starting | checks logged: 0

Zero checks after 25 seconds. No error, no timeout — simply no interval had passed yet.

start_period and start_interval fix exactly this

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres -d lab"]
  interval: 30s          # rhythm during normal operation
  timeout: 3s
  retries: 10
  start_period: 30s      # grace period at startup
  start_interval: 1s     # check every second during the grace period

Same interval: 30s, measured three ways:

ConfigurationApp started after
interval: 30s, nothing else31.73 s
+ start_period: 30s6.81 s
+ start_period: 30s + start_interval: 1s3.75 s
Measured 19/09/2026, identical images and hardware. The middle value comes from the default start_interval (5 s), which applies inside the grace period.

A factor of 8.5 from two lines. start_period delivers the bigger jump, start_interval collects the rest. To be honest about our own process: we initially had only start_interval in mind as the explanation and had to measure the middle row to separate the contributions properly. Measure only the first and last row and you attribute the whole effect to the wrong option.

Most tutorials do recommend start_period — but as protection against false alarms, not as an accelerator. Both are true; the timing effect is rarely quantified.

Finding 4: Your volume name comes from your folder name

Volumes are the reason container data survives a restart. A Docker Compose example:

services:
  keeper:
    image: alpine:3.20
    volumes:
      - lab_data:/data
    command: ["sh","-c","echo important-$(date +%s) >> /data/state.txt; cat /data/state.txt"]
volumes:
  lab_data:

Measured behaviour across three runs:

Run 1:                           important-1789801499
after 'docker compose down':     important-1789801499
                                 important-1789801500     ← data survived
after 'docker compose down -v':  important-1789801501     ← everything gone

down keeps the data, down -v deletes it — with no prompt, no warning, no output. There is no difference in feedback between “stack stopped” and “stack stopped and your database deleted”.

And now the trap that really hurts

By default Compose derives the project name from the directory name. The volume lab_data is actually called:

docker volume ls | grep lab_data
# vol_lab_data        ← prefix = folder name "vol"

We copied the project into a completely different location — with the same folder name — and ran both:

/tmp/dclab/vol         → important-1789801514
/tmp/dclab/kopie/vol   → important-1789801514     ← the same data!
                         important-1789801514

docker volume ls | grep -c "^vol_lab_data$"
# 1                    ← only ONE volume exists

Two separate projects in different locations are reading the same volume. If you keep two client stacks both in a folder called app or docker, you are mixing their data — and you will notice only when production data shows up in your test environment.

The fix is one line:

name: client-project-a    # explicit project name
services:
  ...

Two futuristic cylindrical vessels filled with glowing particles, connected by a radiant stream of light transferring between them

Named volume or bind mount?

volumes:
  - lab_data:/data          # named volume: Docker manages the location
  - ./config:/etc/app:ro    # bind mount: a concrete host path

Our rule of thumb from practice: named volumes for data the application writes (databases, uploads), bind mounts for things you maintain yourself (configuration files, certificates). Bind mounts carrying configuration deserve a trailing :ro — a container that can overwrite its own configuration is a bad idea.

Finding 5: .env is not what you think — it is two separate layers

This is where almost everyone conflates two entirely different things, because they share a filename.

First, precedence. A .env with two variables and a stack that passes both through:

A) .env file only:
   GREETING=from_env_file
   SHELL_VAR=from_env_file

B) shell variable set, .env unchanged:
   GREETING=from_env_file
   SHELL_VAR=from_the_shell     ← shell beats file

A shell environment variable overrides the value from .env. That is useful for one-off deviations (TAG=v2 docker compose up -d) and a source of confusion when an old exported variable is still lying around in your session.

Now the real trap. The .env file sits right next to compose.yaml — does it therefore reach the container?

A) without environment: and without env_file:
   inside container: [NOT_SET]

B) with env_file: .env
   inside container: [from_env_file]

No. Compose reads .env in order to substitute ${...} placeholders inside the YAML file. That is a layer before the container. For a variable to arrive inside the container you need environment: or env_file: — otherwise it is simply absent there, despite the file sitting one metre away.

services:
  app:
    image: alpine:3.20
    env_file: .env              # whole file into the container
    environment:
      DB_HOST: db               # single fixed value
      DB_PASS: ${DB_PASS}       # interpolated from .env

A glowing futuristic platform of translucent golden and teal layers against a dark blue background

An honest mishap from this measurement: our first test run returned from_env_file for both cases — apparently proving that .env is passed through automatically. In reality we had ${GREETING} inside command:, and Compose substituted it on the host while parsing the file. We had measured interpolation, not the container. Only with $$GREETING (literally escaped) did the real difference appear. A test setup that resolves the very layer under investigation will reliably confirm the wrong answer.

And the security rule that follows: .env contains passwords and belongs in .gitignore. What goes into the repository instead is a .env.example with empty values. For real production secrets there is secrets:, which mounts values as files rather than environment variables — environment variables show up in docker inspect and in process listings.

Finding 6: Networks isolate all the way down to name resolution

By default Compose creates one network in which every service can reach every other by its service name. A real stack wants more separation:

services:
  frontend:
    image: alpine:3.20
    networks: [public_net]
  backend:
    image: alpine:3.20
    networks: [public_net, private_net]
  db:
    image: alpine:3.20
    networks: [private_net]
networks:
  public_net:
  private_net:

Measured:

frontend -> db:   NOT REACHABLE
                  ** server can't find db: SERVFAIL
backend  -> db:   REACHABLE

The crucial part is the second line: frontend does not get “connection refused” — it cannot resolve the name db at all. Isolation happens in DNS, not merely in the packet filter. For a database that means it does not exist as far as the publicly reachable service is concerned.

Three glowing glass-like geometric elements in a row, a teal element flanked by two warm yellow spheres containing network nodes

Combined with the 127.0.0.1 rule above, this gives you the basic shape of a safe stack: the database has no ports: mapping at all and is reachable only on the internal network. Only the reverse proxy binds a port, and even that only locally.

services:
  db:
    image: postgres:16-alpine
    networks: [private_net]
    # no ports: — deliberately
  app:
    image: my-app
    networks: [private_net, public_net]
  proxy:
    image: nginx:1.27-alpine
    ports:
      - "127.0.0.1:8080:80"
    networks: [public_net]

restart: what happens when something crashes

restart: on-failure:3

Measured with a container that exits immediately with code 1:

RestartCount: 3
State: exited exit=1

Exactly three attempts, then Docker gives up. The options at a glance:

ValueBehaviour
nodefault, never restart
on-failure[:n]restart only on non-zero exit, optional cap
alwaysalways restart, even after a manual stop when the daemon restarts
unless-stoppedlike always, but respects a manual stop

For server services unless-stopped is usually the right choice: the service returns after a reboot but stays down if you deliberately stopped it.

compose watch: developing without constant rebuilds

Since Compose v2 there is a dedicated mode that watches for file changes:

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

sync copies changed files into the running container, rebuild rebuilds the image — depending on what changed. Source code is synchronised, a changed dependency manifest triggers a genuine rebuild. This replaces the old reflex of running docker compose up --build for every change.

The complete Docker Compose example

Everything measured above, assembled into a stack you can actually run in production:

name: myproject

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    networks: [private_net]
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
      start_interval: 1s

  cache:
    image: redis:7-alpine
    restart: unless-stopped
    networks: [private_net]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 3s
      retries: 5
      start_period: 10s
      start_interval: 1s

  app:
    build: .
    restart: unless-stopped
    networks: [private_net, public_net]
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    env_file: .env
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://cache:6379

  proxy:
    image: nginx:1.27-alpine
    restart: unless-stopped
    networks: [public_net]
    depends_on:
      - app
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

volumes:
  db_data:

networks:
  private_net:
  public_net:

What follows from the measurements above, point by point:

  • Explicit name: — otherwise your folder name decides your volume names (finding 4)
  • 127.0.0.1: before every port — otherwise Docker bypasses your firewall (finding 1)
  • The database has no ports: — it does not need to be reachable from outside (finding 6)
  • condition: service_healthy instead of the short form — otherwise Connection refused (finding 2)
  • start_period plus start_interval — otherwise you wait 30 seconds instead of 4 (finding 3)
  • :ro on the configuration mount — the container should not be able to rewrite its own config
  • Secrets via .env, with the file in .gitignore

Common failures and how to spot them

The container runs, the application still does not answer

A state we already documented during a Shopware installation: the container reports running, the log reports success — and the first request returns HTTP 400. The cause there was the host header, not the container. The lesson generalises: docker compose ps proves that a process is running, not that it is doing the right thing. Always verify with a real request against the service, not against the status.

A YAML error in a line that looks correct

While writing this article we produced this ourselves:

yaml: line 4: mapping values are not allowed in this context

The cause was a colon inside an unquoted command: line. YAML reads it as the separator of a new key. For commands containing colons, brackets or variables, the list form helps:

command: ["sh", "-c", "echo hello: world"]

And before every start: docker compose config prints the fully resolved configuration including every interpolated variable. It is the fastest way to see what Compose actually understood — rather than what you meant.

The stack runs locally but not on the server

Almost always finding 3 in the wild: on a fast development machine the database is ready in 0.4 seconds and wins the race against the app. On a slower server it does not. A stack without condition: service_healthy does not work reliably — it is merely lucky.

Limits of these measurements

What we do not claim, because we did not measure it:

  • No performance numbers on container overhead. Our server is itself a virtual machine without nested virtualisation; meaningful comparisons against bare metal would be invented.
  • No statements about Docker Swarm or Kubernetes. Compose is a tool for a single host. Moving to multiple nodes is a different topic with different rules.
  • The firewall measurement applies to iptables with ufw. On systems running pure nftables or firewalld the chain ordering may differ. The advice stays identical, the precise causal chain does not necessarily.
  • The timings come from one server. Absolute values depend on hardware and load; the ratios (factor 60 between running and ready, factor 8.5 from start_period) are what generalises.

Conclusion

A useful Docker Compose example differs from a pretty one in three places, and all three are invisible until something goes wrong:

First, the port mapping. The short form "8080:80" made a service reachable from the internet in our measurement, while the firewall was active and had never opened that port. This is not an edge case — it is the default in nearly every example online.

Second, depends_on. The short form waits for a container, not for a service. The gap was 1.19 seconds on our machine — enough to produce Connection refused, and exactly the kind of failure that never shows up on your own laptop.

Third, the default values. interval: 30s cost us 31.73 seconds of waiting for a database that was ready after 1.21 seconds. Two lines brought that down to 3.75 seconds.

What all three have in common: they generate no error message. The stack starts, docker compose ps looks healthy, and the consequence only surfaces at the first restart under load or the first port scan from outside. That is precisely why we measured instead of summarised for this article — and precisely why our own mismeasurement around .env stayed in the text.

If you want to go deeper on the server side: our guide to setting up a Linux server covers hardening the host these containers run on, and IT security vulnerabilities shows how an open port turns into a genuine incident.

Frequently asked questions

What is a Docker Compose example in its simplest form?

Four lines of YAML are enough: a services: block, a service name below it, an image: and optionally a ports: entry. Start it with docker compose up -d. Our minimal stack came up in 3.99 seconds and answered with HTTP 200. You no longer need a version: line — Compose explicitly flags it as obsolete.

What is the difference between docker compose and docker-compose?

docker-compose with a hyphen is version 1, written in Python and end of life. docker compose with a space is version 2+, written in Go and shipped as a plugin inside Docker. On current installations the old variant usually does not exist at all — on our test system docker-compose was not installed, while docker compose version reported v5.0.2.

Why does depends_on not wait until my database is ready?

Because the short form of depends_on only controls container start order, not the readiness of the service inside. We timed both moments separately: Docker reported the container as running after 0.02 seconds, while Postgres accepted connections only after 1.21 seconds. The dependent application fails inside that gap with Connection refused. The remedy is a healthcheck on the target service plus condition: service_healthy.

How do I write a healthcheck in Docker Compose correctly?

With a command that genuinely queries the service rather than merely checking the process — pg_isready for Postgres, redis-cli ping for Redis. Equally important are start_period and start_interval: without them the first check only runs after a full interval. For us that meant 31.73 seconds of waiting instead of 3.75 seconds, with an identical database.

Is a port in Docker Compose protected by my firewall?

No, not automatically. A mapping of the form "8412:80" binds to all interfaces, and Docker’s iptables rules are evaluated in the FORWARD path before those of ufw. In our measurement the port was reachable with HTTP 200 from a foreign server on the internet, even though ufw was active and had never opened it. Write ports as "127.0.0.1:8412:80" and put a reverse proxy in front.

How do I test whether my Docker port is really open from the outside?

Not from the server itself. A curl against your own public IP travels over the loopback interface and never passes through the firewall chain — the result is worthless while still looking like a test. Check from a different machine outside your network, for example with nc -zv YOUR-IP PORT or a curl against the public address.

Does docker compose down delete my data?

docker compose down keeps named volumes, docker compose down -v deletes them. We verified this across three runs: after down the data was still there, after down -v the contents were gone. There is no prompt and no warning — the output is indistinguishable from a harmless stop.

Why are two different projects using the same volume?

Because Compose derives the project name from the directory name by default and prefixes the volume with it. Two projects in differently located folders sharing a name — say app — therefore share the volume. We measured it: both stacks read the same file, and only a single volume existed on disk. Always set an explicit name: in your compose.yaml.

Is my .env file automatically passed into the container?

No. Compose reads .env in order to substitute ${...} placeholders inside the YAML file — that happens on the host, one layer before the container. For a variable to arrive inside the container you need env_file: or environment:. In our test the variable was simply unset inside the container without those entries, even though the file sat right next to compose.yaml.

Which restart policy should I choose for server services?

Usually unless-stopped. The service comes back by itself after a host reboot but stays down if you deliberately stopped it. on-failure:n caps the attempts — we measured on-failure:3: exactly three restarts, after which the container stays down with exit code 1.

How do I stop my database being reachable from outside?

Give it no ports: mapping at all and place it on a dedicated internal network. In our measurement a service on the other network could not even resolve the name db — the answer was SERVFAIL, not a refused connection. Isolation therefore applies at the DNS layer, long before the packet filter.