Contents
Docker deployment tutorial: Deploy a website to a cloud server (VPS)
This section brings together what you have learned so far: deploying a website managed with Docker Compose to a Linux cloud server (VPS).
The output in this section simulates the complete workflow on one machine: it uses a local
registry:3instead of Docker Hub or GitHub Container Registry (localhost:5000), and a local certificate issued by Caddy forlocalhostinstead of a certificate for a real domain. For an actual deployment, just replace the image name and domain with your own.
The overall workflow
Local machine or CI ──docker build / push──▶ Registry ──docker compose pull──▶ Cloud server
Build the code into an image on your local machine or in CI, then push it to a registry. The server only downloads and runs the image; it does not build the image. This means the server does not need the source code or build tools, and every deployment produces the same result. To go back to an older version, just switch to its tag.
1. Prepare the server
You can use a Linux server from any cloud provider. This example uses Ubuntu:
-
Log in over SSH, create a regular user, and disable password login (use keys only).
-
Follow the Linux steps in Install Docker to install Docker Engine and the Compose plugin.
-
Open only ports 22 (SSH), 80, and 443 in the firewall:
sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw enableNote: Ports exposed with
-pbypass ufw (see Security best practices), so only the reverse proxy hasportsin the compose.yaml below. -
Configure Docker to start automatically at boot:
sudo systemctl enable docker.
2. The application
This example is a small Node.js + PostgreSQL website. It records a visit on every request and returns the current version and visit count.
myapp/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
└── .dockerignore
server.js:
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const version = process.env.APP_VERSION || "dev";
app.get("/", async (req, res) => {
await pool.query("CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY, at timestamptz DEFAULT now())");
await pool.query("INSERT INTO visits DEFAULT VALUES");
const { rows } = await pool.query("SELECT count(*)::int AS visits FROM visits");
res.json({ app: "myapp", version, visits: rows[0].visits });
});
const server = app.listen(3000, () => console.log(`myapp ${version} listening on port 3000`));
process.on("SIGTERM", () => server.close(() => pool.end().then(() => process.exit(0))));
package.json depends on express and pg. The Dockerfile (see Node.js applications for an explanation) passes the version number in as an ARG and then writes it to ENV, so the app can display its own version:
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
USER node
EXPOSE 3000
CMD ["node", "server.js"]
3. Build and push the image
On your local machine or in CI, use a fixed version number as the tag:
$ docker build --build-arg APP_VERSION=1.0.0 -t localhost:5000/myapp:1.0.0 .
[+] Building 6.7s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 308B 0.0s
=> [internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 63B 0.0s
=> [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 2.4s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> => extracting sha256:e2de96513ba9eb53b431787ec8a65cdde380ac4772a3e4c4b714dcfde2a102b5 0.1s
=> => extracting sha256:3c504d5a797be28f9de43147d49cd8ecb2eea46dfaf7beb8715db695d5f0a7f0 2.1s
=> => extracting sha256:02f40d53b06f6b91fec0cd6a6719656da99270bff8ee54ed8dcdf4c157763fc1 0.1s
=> => extracting sha256:7ce7dcbafb7f750cf0c6be3c30e34372b06dd1182fa67f6ae30782c8150c5444 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 36.48kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 3.5s
=> [5/5] COPY --chown=node:node . . 0.1s
=> exporting to image 0.6s
=> => exporting layers 0.3s
=> => exporting manifest sha256:a0bd86a3b62021adb05a5e710c5a59f9e6287c8c84016ba22f1923bc4aaeeabf 0.0s
=> => exporting config sha256:7f75124e75541f256217e31a844e8c9b687434955d053e47e5dae571aa3944c6 0.0s
=> => exporting attestation manifest sha256:9d6688e6e287f9a0b3d3a95d23765cde8496c89bf6dd0c7fd7a191ac58abe9db 0.0s
=> => exporting manifest list sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 0.0s
=> => naming to localhost:5000/myapp:1.0.0 0.0s
=> => unpacking to localhost:5000/myapp:1.0.0 0.3s
$ docker push localhost:5000/myapp:1.0.0
The push refers to repository [localhost:5000/myapp]
44136fa355b3: Pushed
2297ea3ee753: Pushed
7ce7dcbafb7f: Pushed
fffb74b09a22: Pushed
e2de96513ba9: Pushed
1d73dbdb2af1: Pushed
bd9735416a50: Pushed
02f40d53b06f: Pushed
22ac32835411: Pushed
3c504d5a797b: Pushed
1.0.0: digest: sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 size: 856
In practice, the image name is the registry address followed by your account, such as Docker Hub's yourname/myapp:1.0.0 or GitHub's ghcr.io/yourname/myapp:1.0.0. Run docker login first (see Push to Docker Hub).
If you build on an Apple Silicon Mac and deploy to a regular amd64 server, remember to add
--platform linux/amd64. Otherwise, you will getexec format erroron the server. See Multi-platform images.
4. Files on the server
Create a directory on the server, such as /srv/myapp. You need only three files; you do not need the source code:
/srv/myapp/
├── compose.yaml
├── .env
└── Caddyfile
compose.yaml:
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
app:
image: localhost:5000/myapp:${APP_VERSION}
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
db:
image: postgres:18
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
interval: 5s
retries: 10
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
volumes:
caddy_data:
caddy_config:
pgdata:
.env:
APP_VERSION=1.0.0
DB_PASSWORD=change-this-to-a-long-random-password
Caddyfile (for an actual deployment, replace localhost with your domain, such as example.com):
localhost {
reverse_proxy app:3000
}
Key points:
| Setting | Description |
|---|---|
image: ...:${APP_VERSION} | .env controls the version number; change this one line to update or roll back |
Only caddy has ports | The app and database are not reachable from outside |
| Caddy | Automatically obtains and renews HTTPS certificates and redirects HTTP to HTTPS. See the final section of Nginx and reverse proxies |
pgdata:/var/lib/postgresql | The mount path for PostgreSQL 18. See PostgreSQL |
pg_isready -h 127.0.0.1 | Prevents the database from being marked healthy while it is still initializing |
restart: unless-stopped | Starts the service automatically after a crash or host reboot |
logging | Limits log size to prevent the disk from filling up. See Free up disk space |
.env contains a password, so set its permissions: chmod 600 .env.
5. Start the app
$ ls -a
.
..
.env
Caddyfile
compose.yaml
$ docker compose pull
[+] pull 35/35
✔ Image caddy:2 Pulled 2.8s
✔ Image localhost:5000/myapp:1.0.0 Pulled 0.3s
✔ Image postgres:18 Pulled 12.2s
$ docker compose up -d
[+] up 7/7
✔ Network srv-myapp_default Created 0.0s
✔ Volume srv-myapp_caddy_config Created 0.0s
✔ Volume srv-myapp_caddy_data Created 0.0s
✔ Volume srv-myapp_pgdata Created 0.0s
✔ Container srv-myapp-db-1 Healthy 6.0s
✔ Container srv-myapp-app-1 Started 6.2s
✔ Container srv-myapp-caddy-1 Started 6.7s
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
srv-myapp-app-1 localhost:5000/myapp:1.0.0 "docker-entrypoint.s…" app 10 seconds ago Up 4 seconds 3000/tcp
srv-myapp-caddy-1 caddy:2 "caddy run --config …" caddy 10 seconds ago Up 3 seconds 0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp
srv-myapp-db-1 postgres:18 "docker-entrypoint.s…" db 10 seconds ago Up 9 seconds (healthy) 5432/tcp
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":1}
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":2}
$ docker compose logs app
app-1 | myapp 1.0.0 listening on port 3000
docker compose pulldownloads all images first, including themyapp:1.0.0image we pushed.up -dstarts services in dependency order:appstarts afterdbbecomesHealthy, followed bycaddy.- In
ps, only caddy exposes ports 80 and 443. - Two HTTPS requests changed
visitsfrom 1 to 2, and the data was written to PostgreSQL. (-kis needed because this uses a local certificate; a real domain uses a Let's Encrypt certificate and does not need-k.)
For a real deployment, open https://your-domain to confirm the site works, and use docker compose logs -f app to watch the logs.
6. Update the version
Build and push the new version locally:
$ docker build -q --build-arg APP_VERSION=1.1.0 -t localhost:5000/myapp:1.1.0 .
sha256:aac108fcd63549926e767b781e0b9aa25a86e2f2cd449710a41b5e8d0accda16
$ docker push -q localhost:5000/myapp:1.1.0
localhost:5000/myapp:1.1.0
On the server, change APP_VERSION in .env, then pull and recreate only app:
$ grep APP_VERSION .env
APP_VERSION=1.1.0
$ docker compose pull app
[+] pull 1/1
✔ Image localhost:5000/myapp:1.1.0 Pulled 0.1s
$ docker compose up -d app
[+] up 2/2
✔ Container srv-myapp-db-1 Healthy 0.7s
✔ Container srv-myapp-app-1 Started 1.0s
$ curl -sk https://localhost/
{"app":"myapp","version":"1.1.0","visits":3}
$ docker compose images app
CONTAINER REPOSITORY TAG PLATFORM IMAGE ID SIZE CREATED
srv-myapp-app-1 localhost:5000/myapp 1.1.0 linux/amd64 aac108fcd635 251MB 44 seconds ago
pull appdownloads only the new image forapp. (Because this simulation runs on one machine, the image is already local, so it takes only 0.1 seconds.)up -d appdetects the changed image and recreates only theappcontainer.dbandcaddyare unaffected, and the site is interrupted for about one second.- The response's
versionis now1.1.0, andvisitscontinues from 3. The database data is preserved. docker compose images appconfirms that the current version is1.1.0.
Roll back
If the new version has a problem, change APP_VERSION back to 1.0.0, then run up -d app:
$ grep APP_VERSION .env
APP_VERSION=1.0.0
$ docker compose up -d app
[+] up 2/2
✔ Container srv-myapp-db-1 Healthy 0.7s
✔ Container srv-myapp-app-1 Started 0.9s
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":4}
One command takes you back to the old version, and the old image is still on the server, so there is no need to download it again. This is why you should use a fixed version number instead of latest: with latest, you cannot tell which version is running on the server or clearly return to the previous version.
Database schema changes (migrations) usually cannot be rolled back automatically. Back up the database before releasing a new version, and make database changes in the new version compatible with the old app whenever possible.
7. Back up
At a minimum, back up the database daily and store the files on another machine. Create backup.sh in /srv/myapp:
#!/bin/sh
set -e
cd "$(dirname "$0")"
mkdir -p backup
docker compose exec -T db pg_dump -U app app | gzip > backup/app-$(date +%F).sql.gz
find backup -name 'app-*.sql.gz' -mtime +14 -delete
$ ./backup.sh && ls -lh backup
total 4K
-rw-r--r-- 1 root root 872 Oct 13 01:17 app-2026-10-13.sql.gz
$ gunzip -c backup/app-*.sql.gz | grep -A 6 "^COPY public.visits"
COPY public.visits (id, at) FROM stdin;
1 2026-10-13 01:16:44.277875+00
2 2026-10-13 01:16:44.390504+00
3 2026-10-13 01:16:57.763022+00
4 2026-10-13 01:17:02.44854+00
\.
- The backup file is named with the date.
find ... -mtime +14 -deleteremoves backups older than 14 days. - Decompress the file to check its contents: all 4 records in the
visitstable are there.
Run chmod +x backup.sh, then use crontab -e to schedule it for 3 a.m. every day:
0 3 * * * /srv/myapp/backup.sh
If you keep backups on the same server, they will be lost if the server fails. Use tools such as rsync or rclone to copy backup/ to another machine or cloud storage. See PostgreSQL backup and restore for restore instructions, and test a restore regularly.
8. Automation (optional)
Manually SSHing in to update the app quickly becomes tedious. Common ways to automate this include:
-
CI/CD: Build and push the image in CI such as GitHub Actions, then use SSH to change
APP_VERSIONon the server and rundocker compose pull app && docker compose up -d app. -
Docker context: Operate the remote server's Docker directly from your local machine without logging in to the server first:
docker context create prod --docker "host=ssh://user@your-server" docker --context prod psdocker --context prod compose up -duses thecompose.yamlin your local directory and runs it on the remote server.
Pre-launch checklist
- SSH allows key-based login only, and the firewall opens only 22, 80, and 443
- Only the reverse proxy has
ports; the database is not exposed externally - Image tags use fixed version numbers, not
latest - Every service has
restart: unless-stopped - A log size limit is configured
- The health check is correct (
pg_isready -h 127.0.0.1) -
.envhas permission mode 600 and is not committed to Git - The database is backed up to another location daily, and a restore has been tested
- Old images are cleaned up regularly (see Free up disk space), and images are rebuilt regularly to get security updates (see Security best practices)
Recap
- Build and push the image locally or in CI; the server only pulls and runs it.
- Keep the version number in
.env; update or roll back by changing one line and runningup -d. - Expose only the reverse proxy, and configure the database health check correctly.
- Back up to another machine every day, and test restores regularly.
Exercises
Exercise 1: After rolling APP_VERSION on the server back from 1.1.0 to 1.0.0, is the data written by the new version still in the database?
Answer
Yes. The rollback changed only the app image; the database Volume was not changed. In this chapter's test, visits kept increasing. However, if the new version changed the table schema, the old app might not work with the new schema, so back up before releasing.
FAQ
Do I have to use a registry? Can't I run docker build on the server?
You can, but then the server needs the source code and build tools, and each deployment could produce a different result. With a registry, the server only needs to pull an image that has already been tested, and rollbacks are simpler.
What kind of cloud server should I use?
Any Linux server that can run Docker Engine will work. A small website usually needs only 1–2 GB of memory; add more if your database grows.
Can I automate deployments?
Yes. Use GitHub Actions to build and push an image when you create a version tag, then SSH into the server and update it. See [Automated builds with GitHub Actions](/docker/ci-cd/).
Updated October 14, 2026