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
sudouser. - 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
dockeris missing. - A DNS record for
app.example.compointing at203.0.113.10if the app will serve a domain. - A decision about ports: expose the app on
127.0.0.1:8080and put Nginx in front, or publish it directly.
Confirm the plugin is present:
docker compose versionYou 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.
sudo mkdir -p /opt/apps/myapp
sudo chown deploy:deploy /opt/apps/myapp
cd /opt/apps/myappNow 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.
openssl rand -hex 24Copy the output, then create the file:
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/.envWarning: .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.
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:
cd /opt/apps/myapp
docker compose configThat 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:
docker compose up -dExpect 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 StartedThe -d flag runs everything in the background. Confirm the state:
docker compose psBoth containers should show running, and the database should show healthy. Watch the app boot:
docker compose logs -f appPress 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:
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:
sudo nginx -t && sudo systemctl reload nginxThen 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.
curl -I http://127.0.0.1:8080
curl -I https://app.example.comA 200 or 302 means the app is answering. Then:
- Open
https://app.example.comin a browser and log in. - Create one record, note, or item in the app.
- Restart the stack with
docker compose restartand reload the page. The item should still be there — that proves the volume is doing its job.
Finally, check that the data volumes exist:
docker volume ls | grep myappUpdate the app later
Updating is two commands, but back up first. Anything that touches the database can go wrong.
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 psCompose 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 withsudo 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 thedockergroup. Runsudo 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_onblock withcondition: service_healthyand thehealthcheckon the database. - Environment variables are empty inside the container —
.envis missing, misnamed, or in a different directory than the Compose file. Rundocker compose configto 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
- Read Docker Compose file explained: services, volumes, networks and ports to understand every key you just wrote.
- Learn to split configuration with Docker Compose override files.
- Open a shell inside a running container when you need to debug: docker exec into a container.
