DevPlainly
Self-Hosting

Docker Compose for Beginners: Self-Host Your First App

Learn Docker Compose step by step: install it, write your first compose.yaml, persist data with volumes, connect services and keep everything updated.

· 6 min read

Contents
  1. What you need
  2. Step 1: Install Docker and check Compose
  3. Step 2: Your first compose.yaml
  4. Step 3: Self-host a real app with persistent data
  5. Step 4: Connect multiple services
  6. The commands you will use every day
  7. Updating your apps
  8. Backing up
  9. Security basics before exposing anything
  10. Troubleshooting
  11. FAQ
  12. What is the difference between Docker and Docker Compose?
  13. compose.yaml or docker-compose.yml?
  14. Does a stopped container lose its data?
  15. Further reading

Docker Compose lets you describe an entire application — web server, database, background workers — in one readable file, and start it with one command. It is the easiest way to start self-hosting, and the same file works on your laptop, a $5 VPS or a Raspberry Pi.

By the end of this guide you will have a self-hosted uptime monitor running with persistent data, and you will understand every line of the file that runs it.

What you need

  • A computer or server running Linux, macOS or Windows
  • Docker Engine with the Compose plugin (installed together with Docker Desktop, or separately on Linux)
  • Basic comfort with a terminal

Step 1: Install Docker and check Compose

On Windows or macOS, install Docker Desktop from the official site; Compose is included.

On Ubuntu or Debian, Docker provides a convenience script for test machines:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER   # run docker without sudo (log out and back in)

For production servers, follow the repository-based install instead so updates come through your package manager.

Check that everything works:

docker --version
docker compose version

Note the command is docker compose (with a space). The older standalone docker-compose (with a hyphen) is deprecated.

Step 2: Your first compose.yaml

Create a folder and a file named compose.yaml:

mkdir hello-compose && cd hello-compose
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"

Start it:

docker compose up -d

Open http://localhost:8080 and you should see the nginx welcome page. Here is what each line means:

LineMeaning
services:The list of containers that make up the app
web:A name you choose for this service
image: nginx:alpineThe image to download and run
ports: "8080:80"Map port 8080 on your machine to port 80 in the container
-dRun in the background ("detached")

Stop and remove it with docker compose down.

Step 3: Self-host a real app with persistent data

Now something useful: Uptime Kuma, an open-source monitor that checks whether your websites are up and alerts you when they are not.

mkdir ~/uptime-kuma && cd ~/uptime-kuma
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    restart: unless-stopped
    ports:
      - "3001:3001"
    volumes:
      - ./data:/app/data
docker compose up -d
docker compose logs -f     # watch it start; Ctrl+C to stop watching

Visit http://localhost:3001 (or http://your-server-ip:3001) and create an admin account.

Two new lines matter here:

  • volumes: ./data:/app/data stores the app's database in a data folder next to your compose file. Containers are disposable; without a volume, every update or restart would wipe your monitors.
  • restart: unless-stopped brings the container back after a crash or a server reboot, unless you stopped it yourself.

Step 4: Connect multiple services

Most apps need a database. Compose puts all services in the same file on a private network, where they can reach each other by service name.

services:
  app:
    image: ghcr.io/example/myapp:latest   # replace with your app's image
    restart: unless-stopped
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    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: 5s
      retries: 10

volumes:
  db-data:

Things to notice:

  • The app connects to the host db — the service name — not localhost.
  • The database has no ports: section, so it is reachable only by other containers, not from the internet. That is what you want.
  • depends_on with service_healthy waits until PostgreSQL actually accepts connections, not just until its container starts.
  • db-data is a named volume managed by Docker, an alternative to a folder path.
  • ${DB_PASSWORD} is read from a .env file in the same folder.

Create that .env file and keep it out of version control:

echo "DB_PASSWORD=$(openssl rand -hex 24)" > .env
echo ".env" >> .gitignore

The commands you will use every day

docker compose up -d          # start (or apply changes to) everything
docker compose ps             # what is running
docker compose logs -f app    # follow logs of one service
docker compose restart app    # restart one service
docker compose exec db psql -U app   # open a shell/command inside a container
docker compose down           # stop and remove containers (volumes are kept)
docker compose down -v        # also delete named volumes — this deletes your data

Updating your apps

docker compose pull      # download newer images
docker compose up -d     # recreate containers that have a new image
docker image prune -f    # remove old, unused images

Pin images to a major version (like uptime-kuma:1 or postgres:16-alpine) rather than latest. You get bug fixes when you pull, without a surprise major upgrade that changes the data format.

Backing up

Your data lives in the volume folders. A simple backup for a bind-mounted folder:

docker compose stop
tar czf ~/backups/uptime-kuma-$(date +%F).tar.gz ./data
docker compose start

For databases, use the database's own dump tool so the backup is consistent:

docker compose exec -T db pg_dump -U app app > backup-$(date +%F).sql

Schedule it with cron — see Cron Expressions Explained — and copy backups to a different machine.

Security basics before exposing anything

  • Do not expose databases. Leave out ports: for services that only other containers use.
  • Put a reverse proxy in front (Caddy, Traefik or nginx) to get HTTPS instead of exposing raw ports.
  • Use a firewall: on Ubuntu, ufw allow 22,80,443/tcp then ufw enable. Be aware that ports published by Docker can bypass ufw rules, which is another reason to publish only what you need.
  • Keep secrets in .env, never in a public repository.

Troubleshooting

ProblemLikely causeFix
port is already allocatedSomething else uses that host portChange the left side, e.g. "8081:80"
permission denied on the Docker socketYour user is not in the docker groupsudo usermod -aG docker $USER, then log out and in
App cannot reach the databaseUsing localhost instead of the service nameUse db (the service name) as the host
Data disappears after an updateNo volume configuredAdd a volumes: mapping

FAQ

What is the difference between Docker and Docker Compose?

Docker runs individual containers. Compose describes a group of containers, their networks and volumes in a file and manages them together.

compose.yaml or docker-compose.yml?

Both work. compose.yaml is the current preferred name; older projects use docker-compose.yml.

Does a stopped container lose its data?

Stopping does not delete anything. Removing a container (docker compose down) deletes everything inside it that is not stored in a volume.

Further reading

Keep reading