Running one container with docker run is easy. Running an app, its database, a cache and a reverse proxy — each with the right ports, volumes and environment — gets messy fast. Docker Compose describes the whole stack in one YAML file and starts it with one command.
This tutorial assumes Docker is installed with the Compose plugin (docker compose version should work). If not, see installing Docker on Ubuntu.
The compose file
Create a project directory and a compose.yaml file (the older name docker-compose.yml also works):
mkdir ~/myapp && cd ~/myapp
services:
web:
image: nginx:stable
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
mkdir html && echo '<h1>Hello from Compose</h1>' > html/index.html
docker compose up -d
curl http://localhost:8080
That’s a complete Compose project. The directory name (myapp) becomes the project name, which prefixes container, network and volume names.
A realistic stack: app + database + cache
Here’s a typical web application with PostgreSQL and Redis:
services:
app:
image: ghcr.io/example/myapp:1.4
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
cache:
image: redis:7-alpine
restart: unless-stopped
volumes:
db-data:
And a .env file next to it:
DB_PASSWORD=change-me-to-a-long-random-string
Key ideas in this file:
- Service names are hostnames. Compose creates a private network for the project;
appreaches the database atdb:5432and Redis atcache:6379. No IP addresses, no published database ports. ${DB_PASSWORD}is substituted from.env(or the shell environment). Keep.envout of Git.127.0.0.1:3000:3000publishes the app only on localhost — a reverse proxy on the host forwards public traffic to it. Publishing to0.0.0.0would bypass UFW, because Docker manages its own firewall rules.depends_onwithcondition: service_healthywaits until the database’s health check passes, not merely until its container starts.- Named volume
db-datakeeps the database files when containers are recreated or upgraded.
Everyday commands
Run these from the directory containing compose.yaml:
| Command | What it does |
|---|---|
docker compose up -d |
Create/start everything in the background |
docker compose ps |
Status of the project’s containers |
docker compose logs -f app |
Follow logs of one service |
docker compose exec db psql -U app |
Run a command in a running container |
docker compose pull |
Download newer images |
docker compose up -d (again) |
Recreate only services whose image or config changed |
docker compose restart app |
Restart a service without recreating it |
docker compose stop |
Stop containers, keep them |
docker compose down |
Stop and remove containers and the network |
docker compose down -v |
Same, and delete named volumes (data loss!) |
docker compose config |
Show the final merged config — great for debugging variables |
Updating the stack
docker compose pull
docker compose up -d
docker image prune -f
Compose recreates only the services whose image changed. Pin image versions (postgres:17, not postgres:latest) so an update never surprises you with a major version jump — database major upgrades in particular need a manual migration.
Building your own image
Instead of image:, a service can build from a Dockerfile:
services:
app:
build:
context: .
dockerfile: Dockerfile
image: myapp:local
docker compose build builds it; docker compose up -d --build builds and restarts in one go. See the Dockerfile tutorial for writing the Dockerfile itself.
Bind mounts vs named volumes
volumes:
- ./config:/etc/myapp:ro # bind mount: a host directory
- app-data:/var/lib/myapp # named volume: managed by Docker
Use bind mounts for config files you edit on the host. Use named volumes for data the container owns (databases, uploads); Docker handles permissions and the location (/var/lib/docker/volumes/). Either way, include them in your backups.
Multiple environments with override files
Compose automatically merges compose.override.yaml if it exists. A common pattern:
compose.yaml— production settings.compose.override.yaml— local development (source code mounted, debug ports), not deployed.
Or name files explicitly:
docker compose -f compose.yaml -f compose.prod.yaml up -d
Limit resources
On a small VPS, stop one runaway container from starving the rest:
services:
app:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
Docker Compose applies these limits without Swarm.
Common mistakes
- Using
localhostbetween containers. Inside a container,localhostis the container itself. Use the service name (db,cache). - Running
down -vby habit. It deletes your database volume. - Editing
.envand expecting running containers to change. Rundocker compose up -dto recreate them with the new values. - Publishing every port. Only the entry point (or reverse proxy) needs a public port.
Next steps
- Put several Compose apps behind one domain with an Nginx reverse proxy for Docker containers.
- Keep the Docker commands cheat sheet handy.
Compose is the simplest way to run self-hosted apps on a single server. A Docker VPS with a few gigabytes of RAM comfortably runs a full stack like the one above.