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.

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.1unless 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:
| Event | Time after up |
|---|---|
Docker reports container state running | 0.02 s |
| Postgres actually accepts connections | 1.21 s |
| Gap where “running” and “ready” diverge | 1.19 s |
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.

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:
interval | App started after |
|---|---|
2s | 3.58 s |
10s | 11.68 s |
30s | 31.73 s |
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:
| Configuration | App started after |
|---|---|
interval: 30s, nothing else | 31.73 s |
+ start_period: 30s | 6.81 s |
+ start_period: 30s + start_interval: 1s | 3.75 s |
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:
...

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

An honest mishap from this measurement: our first test run returned
from_env_filefor both cases — apparently proving that.envis passed through automatically. In reality we had${GREETING}insidecommand:, 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.

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:
| Value | Behaviour |
|---|---|
no | default, never restart |
on-failure[:n] | restart only on non-zero exit, optional cap |
always | always restart, even after a manual stop when the daemon restarts |
unless-stopped | like 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_healthyinstead of the short form — otherwiseConnection refused(finding 2)start_periodplusstart_interval— otherwise you wait 30 seconds instead of 4 (finding 3):roon 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
iptableswith ufw. On systems running purenftablesorfirewalldthe 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
runningand ready, factor 8.5 fromstart_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.
