Docker Compose basics: run a small app and its database together

Running one container with docker run is easy. Running an app, its database and a cache, each with ports, volumes and settings, means a very long command per service, and nobody can remember them all six months later. Docker Compose solves that: you describe the whole setup in one file, and start or stop everything with a single command. For a VPS running a few small projects, it is the tidy way to work.

What Compose gives you

  • One file that documents how your services fit together.
  • One command to start, stop, update or rebuild everything.
  • Repeatability: copy the file to another server and get the same result.
  • Networking for free: services can reach each other by name.

It is installed as a plugin if you followed How to install Docker on Ubuntu the official way. Check it with docker compose version. Note the space: the modern command is docker compose, not docker-compose.

A first example

Create a folder for the project and a file called compose.yaml inside it:

services:
  db:
    image: mariadb:11
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_DATABASE: appdb
      MARIADB_USER: app
      MARIADB_PASSWORD: ${DB_PASSWORD}
    volumes:
      - dbdata:/var/lib/mysql

  app:
    image: my-app:latest
    restart: unless-stopped
    depends_on:
      - db
    environment:
      DB_HOST: db
      DB_NAME: appdb
      DB_USER: app
      DB_PASSWORD: ${DB_PASSWORD}
    ports:
      - "127.0.0.1:3000:3000"

volumes:
  dbdata:

Indent with spaces only, never tabs. See How to edit config files without breaking them for YAML pitfalls.

What the pieces mean

  • services lists each container. Their names, here db and app, are also their network names, so the app reaches the database at the host name db.
  • image is what to run. Pin a version, such as mariadb:11, rather than latest for databases, so an update does not surprise you.
  • restart: unless-stopped brings it back after a crash or reboot.
  • environment passes settings in. Values like ${DB_PASSWORD} are read from a file named .env next to compose.yaml.
  • volumes keeps data outside the container. Without it, the database disappears when the container is recreated.
  • ports publishes a port. The 127.0.0.1: prefix keeps it private to the server, so only a reverse proxy on the same machine can reach it. That matters, because Docker bypasses the firewall for published ports. See Set up a firewall on your VPS with UFW.
  • depends_on starts db before app. It waits for the container to start, not for the database to be ready, so your app should retry its connection.

The secrets file

Create .env in the same folder:

DB_ROOT_PASSWORD=a-long-random-password
DB_PASSWORD=another-long-random-password

Keep it out of version control, and restrict who can read it: chmod 600 .env. Never put real passwords inside compose.yaml if you commit that to a repository.

The commands you will use

docker compose up -d
docker compose ps
docker compose logs -f app
docker compose down
  • up -d creates and starts everything in the background.
  • ps shows the status of the services.
  • logs -f app follows one service's output. Read it the same way as any log. See How to read your server logs.
  • down stops and removes the containers. Named volumes stay, so your data survives. Add -v only when you want to delete the data too, because that is irreversible.

Updating

To pull newer images and restart only what changed:

docker compose pull
docker compose up -d

Before updating a database image to a new major version, read its release notes, back up first and be ready to roll back. Database upgrades can change the on-disk format.

Backing up

The data lives in volumes or mounted folders. Back those up on a schedule, and test a restore. For databases, a dump is safer than copying live files. See How to back up your VPS with rsync and The 3-2-1 backup rule.

Putting it behind a web server

For a website, keep the container's port private, as in the example, and let nginx handle HTTPS and the public address. The configuration is similar to Run a Node.js app on your VPS with PM2 and nginx, pointing proxy_pass at the published local port.

Common problems

ProblemLikely cause
"yaml: line X" errorA tab, or bad indentation
App cannot reach the databaseWrong host name. Use the service name, db, not localhost
Database empty after recreatingNo volume was set
Variables show as blank.env missing, or in a different folder
Port already in useAnother program uses that port
Disk fills upOld images and logs. Run docker system prune carefully

Compose is a small tool with a big payoff. A well-kept compose.yaml is also documentation, so the next person, or you in half a year, knows how everything runs.


Still stuck? Open a ticket on Discord - run /ticket in any channel, pick a department, and include your Server ID or Invoice ID so we can jump straight in.