Docker Compose turns a messy collection of docker run commands into a single declarative file: one docker-compose.yml describes every service, network, and volume your app needs, and one command (docker compose up) brings the whole stack to life. In this tutorial you will install Docker and the Compose plugin on Ubuntu 24.04 or 22.04 the official way, then build and run your first real multi-container stack: an Nginx web server backed by a PostgreSQL database, with persistent data and health checks.
This guide is for you if you can run basic Linux commands and want a reproducible, shareable way to run multi-service apps on a VPS or your own machine. By the end you will know how to write a compose file from scratch, manage the stack day to day, and debug the five errors almost everyone hits the first time.
Prerequisites
- An Ubuntu 24.04 or 22.04 system (VPS or local machine) with at least 1 GB RAM. 2 GB is more comfortable when running a database alongside a web server.
- A non-root user with
sudoprivileges. - Internet access, so the machine can reach Docker's official package repository.
- Ports 8080 (web) and 5433 (database) free. If something already listens there, the troubleshooting section below shows how to find and move it.
Step 1: Update the system and install dependencies
Start from a current package index, then install the small set of tools needed to add Docker's repository securely:
sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl
The first command refreshes package lists and applies pending updates so nothing conflicts later. ca-certificates lets apt trust HTTPS sources, and curl fetches Docker's signing key in the next step.
Step 2: Add Docker's official GPG key
Docker signs its packages; your system should verify that signature. Create a keyring directory and store the key with world-readable permissions:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo tee /etc/apt/keyrings/docker.asc > /dev/null
sudo chmod a+r /etc/apt/keyrings/docker.asc
This is the current, recommended way of trusting a third-party repository: the key lives in /etc/apt/keyrings and is referenced explicitly by the repository entry, instead of being dumped into the global trusted key store (the old apt-key approach is deprecated).
Step 3: Add the Docker repository
Register Docker's Ubuntu repository, automatically detecting your CPU architecture and Ubuntu codename (noble for 24.04, jammy for 22.04):
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
The signed-by option pins this repository to the key you just added, so packages claiming to be from Docker but signed otherwise would be rejected. The final apt update makes apt aware of Docker's packages.
Step 4: Install Docker Engine and the Compose plugin
Install the engine, its CLI, the container runtime, the build plugin, and the Compose plugin (this is the modern Compose v2, invoked as docker compose with a space):
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker-compose-plugin is the key package: it registers docker compose as a first-class Docker subcommand. The old standalone docker-compose Python binary (Compose v1) is deprecated; do not install it.
Step 5: Verify the installation
Confirm both the engine and Compose respond, and that the Docker daemon is running:
docker --version
docker compose version
sudo systemctl status docker --no-pager
You should see something like Docker version 27.x and Docker Compose version v2.x. If systemctl status shows active (running), the daemon is healthy and will start on boot (the package enables the service automatically).
Step 6: Let your user run Docker without sudo
Typing sudo before every Docker command gets old fast and breaks scripts. Add your user to the docker group, then apply the new group membership:
sudo usermod -aG docker $USER
newgrp docker
usermod -aG appends (-a) the docker group to your user's groups. newgrp docker activates it in the current shell; alternatively, log out and log back in. Test with a harmless command:
docker run hello-world
If you see the "Hello from Docker!" message, the engine is working for your user.
Step 7: Create your project directory and web content
Compose projects live in a directory with a docker-compose.yml file. Make one for this tutorial and add a small HTML page Nginx will serve:
mkdir -p ~/compose-demo/html && cd ~/compose-demo
cat > html/index.html <<'EOF'
<!DOCTYPE html>
<html><head><title>My First Compose Stack</title></head>
<body>
<h1>It works! Nginx is running inside Docker Compose.</h1>
<p>Served by the "web" service, backed by the "db" service.</p>
</body></html>
EOF
Everything is scoped to ~/compose-demo: the compose file, the HTML, and (via named volumes) the database data. Keeping one project per directory is the standard layout and avoids name clashes between projects.
Step 8: Write your first docker-compose.yml
Create the compose file. It defines two services: web (Nginx serving your HTML on port 8080) and db (PostgreSQL with a password and a named volume so data survives restarts):
cat > docker-compose.yml <<'EOF'
services:
web:
image: nginx:alpine
container_name: compose-demo-web
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
container_name: compose-demo-db
environment:
POSTGRES_USER: demo
POSTGRES_PASSWORD: strong-demo-password-123
POSTGRES_DB: demoapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U demo -d demoapp"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
EOF
What each piece does: image picks the container image and tag. ports maps host port 8080 to container port 80. The volumes mount under web makes your local html folder appear inside the container read-only (:ro), so editing files on the host changes the site instantly. db stores data in the named volume pgdata, which Docker manages and which survives container rebuilds. depends_on with service_healthy waits for Postgres's healthcheck to pass before starting Nginx, and restart: unless-stopped brings everything back after a reboot.
Step 9: Start the stack
From the project directory, pull the images and start everything in the background:
cd ~/compose-demo
docker compose up -d
The -d flag runs containers detached (in the background). The first run downloads nginx:alpine and postgres:16-alpine, creates the pgdata volume and a default network, then starts both containers. Follow the progress:
docker compose ps
docker compose logs -f
docker compose ps shows the containers and their state; logs -f tails output from all services (Ctrl+C exits the tail without stopping anything).
Step 10: Verify it worked
Check the web service responds and the database accepts connections:
curl -s http://localhost:8080 | head -5
docker compose exec db psql -U demo -d demoapp -c "SELECT version();"
The curl should print your index.html ("It works!"). The second command opens a shell inside the running db container and runs a real SQL query against PostgreSQL, proving the database is healthy and reachable over the compose network.
Step 11: Manage the stack day to day
A handful of commands cover almost all daily work with a compose project:
docker compose stop # stop containers, keep data and containers
docker compose start # start them again
docker compose restart web # restart one service only
docker compose down # stop AND remove containers/networks (volumes kept)
docker compose down -v # also delete volumes (database data GONE)
docker compose up -d --build # rebuild images and restart
Learn the difference between down and down -v: the plain form keeps your named volumes, so a database survives; the -v form wipes them, which is useful for a truly fresh start but destroys data.
Step 12: Use a .env file instead of hardcoding secrets
Hardcoding the database password in the compose file is fine for a demo but bad in real projects. Compose reads variables from a .env file automatically:
cat > .env <<'EOF'
POSTGRES_USER=demo
POSTGRES_PASSWORD=strong-demo-password-123
POSTGRES_DB=demoapp
EOF
Then reference them in docker-compose.yml:
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
After changing the file, apply it with docker compose up -d. Add .env to .gitignore so secrets never reach version control. For production, consider Docker secrets or a vault instead of plain env files.
Common issues & fixes
1. "permission denied while trying to connect to the Docker daemon"
Your shell does not have the new docker group membership yet. Run newgrp docker, or fully log out and back in, then retry. If it still fails, confirm you are in the group with groups.
2. "port is already allocated" when running up
Something on the host is using port 8080 or 5433. Find it with sudo ss -tlnp | grep ':8080', stop the conflicting service, or change the left-hand side of the port mapping in your compose file (e.g. "8081:80").
3. "docker: 'compose' is not a docker command"
The Compose plugin is missing, or you typed docker-compose (hyphen) while only the plugin is installed. Reinstall it: sudo apt install -y docker-compose-plugin, then verify with docker compose version.
4. Postgres container exits immediately with "database password not set"
The postgres image refuses to start without a password. Make sure POSTGRES_PASSWORD is set in the service's environment block (or via .env). After fixing, recreate the container: docker compose up -d --force-recreate db.
5. "services must be a mapping" or YAML errors on up
Almost always an indentation problem in docker-compose.yml: YAML needs consistent two-space indentation, and tabs are forbidden. Re-indent carefully or paste the file from Step 8 again. You can sanity-check with docker compose config, which prints the parsed file or the exact line of the error.
Conclusion
You now have Docker Engine and Compose installed from Docker's official repository, and a working two-service stack defined entirely in one file. That file is documentation, version control, and deployment script in one: hand it to a colleague (minus the .env) and they get the exact same environment with one command. From here, natural next steps are adding a reverse proxy with HTTPS, multi-stage Dockerfiles for your own app, and Compose profiles for dev/prod variants.
If you would rather have your server and container setup handled end to end, XpertBees offers professional server setup, Docker deployments, and managed VPS administration — drop us a message and we will take it from here.



