Deploy a Docker Compose app to a Linux server: install Docker Engine, copy your compose file, pull images, add HTTPS, update, roll back and back up.

Bishal Biswas
Self Learner
From your laptop to the internet
Your notes-api app runs with docker compose up on your computer. Now you want other people to use it. The simplest real way is one Linux server (often called a VPS, a virtual private server, rented from a cloud provider) running Docker and the same compose file.
This is a great setup for small and medium apps. It is cheap, easy to understand, and uses exactly the skills you already have. In this lesson you will:
- prepare an Ubuntu 24.04 server and install Docker Engine,
- write a production compose file that pulls images instead of building them,
- keep secrets in a
.envfile on the server, - put a reverse proxy with HTTPS in front,
- deploy updates, roll back, and back up the database.
You need a server you can reach with SSH. If SSH is new to you, read SSH, Users and Permissions first.
The plan
Internet
|
| https://notes.example.com (ports 80 and 443)
v
+------------------- Linux server -------------------+
| nginx on the host (HTTPS with Let's Encrypt) |
| | |
| | http://127.0.0.1:3000 |
| v |
| [ api container ] ---- [ db container ] - volume |
| Docker Compose project in /opt/notes |
+----------------------------------------------------+
The images are built by CI and stored in a registry, as you set up in Building and Pushing Images in GitHub Actions. The server never builds anything. It only pulls and runs.
Step 1: prepare the server
Log in and update the system:
ssh root@203.0.113.10 # use your server's IP address
apt-get update && apt-get upgrade -y
Create a normal user for deployments, so you do not work as root every day:
adduser deploy
usermod -aG sudo deploy
Install Docker Engine
On a server, install Docker Engine (not Docker Desktop) from Docker’s own apt repository. The version in Ubuntu’s default repository is often old and may not include the Compose plugin. Follow the current steps on docs.docker.com for Ubuntu. At the time of writing they look like this:
# Add Docker's signing key
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository
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
# Install Docker Engine, Buildx and the Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Check it:
sudo docker version
docker compose version
Both commands should print a version number. You learned the details in Installing Docker on Windows, macOS and Linux.
Warning: Adding
deployto thedockergroup (sudo usermod -aG docker deploy) lets it run Docker withoutsudo. This is convenient, but anyone who can log in asdeployeffectively has root on the server. Protect that account with SSH keys only, no passwords.
Set up a firewall
Allow only SSH and web traffic:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Warning: Docker writes its own firewall rules for published ports, and these rules are applied before ufw’s rules. So
ports: - "3000:3000"is open to the whole internet even if ufw blocks port 3000. The safe habit: publish ports only on localhost, like"127.0.0.1:3000:3000", and let the reverse proxy be the only door from outside.
Step 2: the production compose file
On the server, create a folder for the app:
sudo mkdir -p /opt/notes
sudo chown deploy:deploy /opt/notes
cd /opt/notes
Your development compose file probably has build:, bind mounts for live code and maybe develop.watch. The production file is different:
| Development | Production |
|---|---|
build: . |
image: ghcr.io/your-name/notes-api:${API_TAG} |
| Bind mount of source code | No source code on the server at all |
| Ports open on all interfaces | 127.0.0.1 only, behind a proxy |
| No restart policy | restart: unless-stopped |
| Default logging | Log rotation |
The full file is in the code panel. Copy it to /opt/notes/compose.yaml on the server, for example with scp:
# Run this on your own computer
scp compose.prod.yaml deploy@203.0.113.10:/opt/notes/compose.yaml
You learned how to keep development and production settings apart in Compose Profiles, Override Files and Multiple Environments.
Secrets in a .env file
Compose reads a file called .env in the same folder and uses it to fill in ${VARIABLES}. Create it on the server only:
nano /opt/notes/.env
chmod 600 /opt/notes/.env # only the owner can read it
API_TAG=1.4.2
POSTGRES_PASSWORD=change-me-to-a-long-random-string
Generate a strong password with:
openssl rand -base64 24
This file never goes into Git and never into an image. See Environment Variables and Secrets in Compose.
Log in to the registry (private images only)
If your image is private on GHCR, log in once on the server with a token that only has the read:packages permission:
docker login ghcr.io -u your-name
Step 3: start it
cd /opt/notes
docker compose pull # download the images named in compose.yaml
docker compose up -d # start everything in the background
docker compose ps
NAME IMAGE STATUS PORTS
notes-api-1 ghcr.io/your-name/notes-api:1.4.2 Up 20 seconds (healthy) 127.0.0.1:3000->3000/tcp
notes-db-1 postgres:17-alpine Up 31 seconds (healthy) 5432/tcp
Test it from the server itself:
curl http://127.0.0.1:3000/health
ok
Because of restart: unless-stopped, these containers start again automatically after a server reboot. Docker Engine itself starts at boot through systemd.
Step 4: a reverse proxy with HTTPS
Your app should not face the internet directly. A reverse proxy receives all web traffic, handles HTTPS (encryption), and forwards requests to your app. You learned why in Reverse Proxies and Load Balancers.
A simple, common choice is nginx installed on the host, plus certbot to get a free certificate from Let’s Encrypt. First, point your domain’s DNS A record to the server’s IP address (see How DNS Works). Then:
sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo nano /etc/nginx/sites-available/notes
server {
listen 80;
server_name notes.example.com;
location / {
proxy_pass http://127.0.0.1:3000; # the published port of the api container
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;
}
}
Turn it on and get the certificate:
sudo ln -s /etc/nginx/sites-available/notes /etc/nginx/sites-enabled/
sudo nginx -t # check the config for errors
sudo systemctl reload nginx
sudo certbot --nginx -d notes.example.com # adds HTTPS to the config for you
Certbot also sets up automatic renewal. Now open https://notes.example.com/health in your browser.
Note: You can also run the reverse proxy as a container in the same compose project. The capstone, Capstone: Containerize a Full-Stack App, uses an nginx container this way. Tools like Caddy or Traefik can also get certificates automatically. All of these are good choices; pick one and learn it well.
Step 5: deploying an update
CI builds and pushes 1.4.3. To deploy it, change one line and run two commands:
cd /opt/notes
sed -i 's/^API_TAG=.*/API_TAG=1.4.3/' .env # or edit .env by hand
docker compose pull api
docker compose up -d
up -d compares the running containers with the file. Only api changed, so only api is recreated. The database keeps running.
Watch it come up:
docker compose ps
docker compose logs -f --tail 50 api
Clean up old images now and then so the disk does not fill:
docker image prune -f
Note: During
up -d, the old api container stops before the new one starts, so there are a few seconds of downtime. For most small apps that is fine. Zero-downtime deployments need more than one copy of the app and a proxy that switches between them. See Deployment Strategies.
Rolling back
Because every release has its own tag, going back is the same as going forward:
sed -i 's/^API_TAG=.*/API_TAG=1.4.2/' .env
docker compose up -d
The old image is probably still on the server, so this takes seconds. This is why you never deploy latest: with latest, there is no “previous version” to go back to.
Deploying from your computer or from CI
You do not have to log in to the server for every deploy. One SSH command can do it:
ssh deploy@203.0.113.10 'cd /opt/notes && docker compose pull && docker compose up -d'
A CI job can run the same command after the image is pushed, which gives you continuous deployment. See Continuous Delivery vs Continuous Deployment.
Docker also supports contexts, which point your local docker command at a remote server over SSH:
docker context create prod --docker "host=ssh://deploy@203.0.113.10"
docker --context prod ps
Step 6: backups
Your images can always be pulled again. Your data cannot. Back up the database regularly with pg_dump, run inside the db container:
cd /opt/notes
docker compose exec -T db pg_dump -U notes notes > backup-$(date +%F).sql
-Tturns off the terminal, which is needed when you redirect output to a file.- The file is written on the host, because the
>is handled by your shell on the server.
To restore into a fresh database:
docker compose exec -T db psql -U notes notes < backup-2026-10-25.sql
Copy backups off the server too (to another machine or to object storage). A backup on the same disk is lost if the server is lost. For more ways to back up volumes, see tmpfs Mounts and Backing Up Volumes.
Tip: Run the backup command every night with a cron job (
crontab -e). Test a restore at least once, so you know your backups actually work.
Keeping it healthy
A short list of habits for a single Docker server:
- Log rotation is set (see Logging Drivers and Log Rotation).
- Disk space: check with
df -handdocker system df. - Updates: run
sudo apt-get update && sudo apt-get upgraderegularly, which also updates Docker Engine. - Base images: rebuild your images in CI with fresh bases, and redeploy.
- Monitoring: at least an outside check that calls
https://notes.example.com/healthand alerts you when it fails.
Common mistakes
Publishing the database port
ports: - "5432:5432" on a server puts your database on the internet, and ufw will not stop it. Remove the ports entry for the database. The api reaches it through the compose network by the name db.
Building on the server
Running docker compose build on the server means your source code lives there, builds use the server’s CPU, and you cannot be sure what is running. Build once in CI, then pull the exact same image everywhere.
Forgetting the volume
Without a named volume for /var/lib/postgresql/data, your data is inside the container and is gone when the container is recreated. Check that docker volume ls shows your volume.
Running docker compose down -v
down is fine. down -v also deletes the volumes, including your database. Never use -v on a production server unless you mean it.
Quick recap
- A single Linux server with Docker Engine and Compose is a simple, solid way to run small apps.
- Install Docker from Docker’s apt repository; the
dockergroup is root-equivalent. - Docker’s published ports bypass ufw, so publish app ports on
127.0.0.1and put a reverse proxy in front. - The production compose file uses
image:with a tag variable,restart: unless-stopped, health checks and log rotation. - Keep secrets in a
.envfile withchmod 600, never in Git. - Update by changing the tag and running
docker compose pullanddocker compose up -d; roll back the same way. - Back up the database with
pg_dumpand store copies somewhere else.
Next, you will see how all of this maps to Kubernetes in From Docker to Kubernetes: What Comes Next.
# /opt/notes/compose.yaml: production compose file for notes-api on a Linux server
# Values like ${API_TAG} come from /opt/notes/.env (chmod 600, never in git):
# API_TAG=1.4.2
# POSTGRES_PASSWORD=change-me-to-a-long-random-string
# Deploy or update: docker compose pull && docker compose up -d
# Roll back: set API_TAG to the old version, then docker compose up -d
name: notes
# Log rotation for every service, so logs never fill the disk
x-logging: &default-logging
driver: local
options:
max-size: "10m"
max-file: "3"
services:
api:
# Built and pushed by CI; the server only pulls, never builds
image: ghcr.io/your-name/notes-api:${API_TAG:?set API_TAG in .env}
environment:
NODE_ENV: production
PORT: "3000"
DATABASE_URL: postgres://notes:${POSTGRES_PASSWORD}@db:5432/notes
# Only the reverse proxy on this server can reach it (Docker ports bypass ufw)
ports:
- "127.0.0.1:3000:3000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
logging: *default-logging
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: notes
POSTGRES_DB: notes
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
# The data lives in a named volume and survives container updates
volumes:
- pgdata:/var/lib/postgresql/data
# No ports: the database is never reachable from outside
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
logging: *default-logging
volumes:
pgdata:
#
# Backup: docker compose exec -T db pg_dump -U notes notes > backup-$(date +%F).sql
# Restore: docker compose exec -T db psql -U notes notes < backup.sql
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready