Docker & DevOps

Deploy a Self-Hosted App with Docker Compose from Scratch

Write a compose.yaml, keep your secrets in.env, start the stack with docker compose up -d, then verify it and update it later without losing data.

BeginnerLinux server7 min read

To deploy a self-hosted app with Docker Compose you need three things: a compose.yaml file, an .env file holding your secrets, and one command — docker compose up -d. This guide takes you from a fresh Linux VPS to a running stack, then shows how to check it and update it later without losing data.

The app itself is whatever you chose: a notes server, a dashboard, a small API. Compose does not care. What matters is where the files live, how secrets are passed in, and where the data is stored.

Before you start

You need:

  • A Linux VPS running Ubuntu 22.04/24.04, Debian 12, or AlmaLinux/Rocky 8–9, with root or a sudo user.
  • SSH access. If you have not connected yet, follow Connect to your Linux VPS with SSH.
  • Docker Engine and the Compose plugin installed. See Install Docker and Docker Compose on a Linux VPS if docker is missing.
  • A DNS record for app.example.com pointing at 203.0.113.10 if the app will serve a domain.
  • A decision about ports: expose the app on 127.0.0.1:8080 and put Nginx in front, or publish it directly.

Confirm the plugin is present:

Bash
docker compose version

You want output like Docker Compose version v2.x. The old docker-compose binary with a hyphen is not needed here.

Create the app directory and.env file

Keep every self-hosted app in its own directory. That directory holds the Compose file, the environment file, and any bind mounts.

Bash
sudo mkdir -p /opt/apps/myapp
sudo chown deploy:deploy /opt/apps/myapp
cd /opt/apps/myapp

Now generate a database password and write the environment file. Compose reads a file called .env in the same directory and substitutes ${VARIABLE} references in the Compose file.

Bash
openssl rand -hex 24

Copy the output, then create the file:

Bash
cat > /opt/apps/myapp/.env <<'EOF'
POSTGRES_PASSWORD=paste-the-generated-password-here
APP_BASE_URL=https://app.example.com
EOF
chmod 600 /opt/apps/myapp/.env

Warning: .env holds real credentials. Keep it at chmod 600, never commit it to Git, and never paste it into a support ticket.

Tip: Commit a .env.example with empty values instead. That documents which variables the stack needs without leaking them.

Write the compose.yaml file

The vendor of your app usually publishes a Compose file — start from that if it exists, and check their docs for the current image tag. The template below is a two-service stack: the app and a PostgreSQL database with a health check.

YAML
services:
  app:
    image: your-app-image:latest
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_URL: postgres://appuser:${POSTGRES_PASSWORD}@db:5432/appdb
      BASE_URL: ${APP_BASE_URL}
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - app_data:/data

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  app_data:
  db_data:

Three things to notice. The app connects to the database by service name — db, not localhost, because Compose puts both containers on a private network. The published port is bound to 127.0.0.1, so only the VPS itself can reach it; a reverse proxy will handle the public side. Both services store data in named volumes, which survive container restarts and rebuilds.

If you want a line-by-line explanation of every key, read Docker Compose file explained: services, volumes, networks and ports.

Deploy a self-hosted app with Docker Compose

Check that your variables resolve before starting anything:

Bash
cd /opt/apps/myapp
docker compose config

That prints the file with ${POSTGRES_PASSWORD} replaced. If you see an empty value, the .env file is in the wrong directory or has the wrong name.

Now start the stack:

Bash
docker compose up -d

Expect output like this:

[+] Running 5/5
 ✔ Network myapp_default    Created
 ✔ Volume "myapp_db_data"   Created
 ✔ Volume "myapp_app_data"  Created
 ✔ Container myapp-db-1     Healthy
 ✔ Container myapp-app-1    Started

The -d flag runs everything in the background. Confirm the state:

Bash
docker compose ps

Both containers should show running, and the database should show healthy. Watch the app boot:

Bash
docker compose logs -f app

Press Ctrl+C to stop following — the containers keep running. For more on log handling, see View and rotate Docker container logs.

Put a reverse proxy in front

The app listens on 127.0.0.1:8080, so it is reachable from the VPS but not from the internet. Point Nginx at it and terminate TLS there:

Nginx
server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Test and reload:

Bash
sudo nginx -t && sudo systemctl reload nginx

Then issue a certificate with your preferred ACME client. The full walkthrough is in Set up Nginx as a reverse proxy for any backend app.

Warning: If you publish a container port with -p 8080:8080 instead of 127.0.0.1:8080:8080, Docker writes its own iptables rules and can bypass UFW. Read Stop Docker from bypassing your UFW firewall before you open anything to the internet.

Check it works

Test from the VPS first, then from outside.

Bash
curl -I http://127.0.0.1:8080
curl -I https://app.example.com

A 200 or 302 means the app is answering. Then:

  1. Open https://app.example.com in a browser and log in.
  2. Create one record, note, or item in the app.
  3. Restart the stack with docker compose restart and reload the page. The item should still be there — that proves the volume is doing its job.

Finally, check that the data volumes exist:

Bash
docker volume ls | grep myapp

Update the app later

Updating is two commands, but back up first. Anything that touches the database can go wrong.

Bash
cd /opt/apps/myapp
docker compose exec db pg_dump -U appuser appdb > /root/appdb-$(date +%F).sql
docker compose pull
docker compose up -d
docker compose ps

Compose recreates only the containers whose image changed. Named volumes are reused, so your data stays. If the new version fails to start, pin the previous tag in compose.yaml and run docker compose up -d again.

Warning: Never run docker compose down -v on a live stack unless you intend to delete every named volume, including the database. The -v flag removes them permanently.

To run different settings in development and production without editing the main file, see Docker Compose override files: separate dev and production configs.

Troubleshooting

  • Error response from daemon: port is already allocated — something already listens on that host port. Find it with sudo ss -tulpn | grep 8080, then stop the old service or change the left-hand side of the port mapping.
  • permission denied while trying to connect to the Docker daemon socket — your user is not in the docker group. Run sudo usermod -aG docker deploy, log out and back in, and retry.
  • The app container restarts in a loop and logs show a database connection error — the app started before PostgreSQL was ready. Keep the depends_on block with condition: service_healthy and the healthcheck on the database.
  • Environment variables are empty inside the container — .env is missing, misnamed, or in a different directory than the Compose file. Run docker compose config to see the resolved values.
  • Data disappeared after an update — a volume was removed, or the app was writing to a path that is not mounted. Compare the container's data directory with the volumes listed in compose.yaml; Docker volumes vs bind mounts explains the difference.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.