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:3 instead of Docker Hub or GitHub Container Registry (localhost:5000), and a local certificate issued by Caddy for localhost instead 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:

  1. Log in over SSH, create a regular user, and disable password login (use keys only).

  2. Follow the Linux steps in Install Docker to install Docker Engine and the Compose plugin.

  3. Open only ports 22 (SSH), 80, and 443 in the firewall:

    sudo ufw allow OpenSSH
    sudo ufw allow 80,443/tcp
    sudo ufw enable

    Note: Ports exposed with -p bypass ufw (see Security best practices), so only the reverse proxy has ports in the compose.yaml below.

  4. 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 get exec format error on 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:

SettingDescription
image: ...:${APP_VERSION}.env controls the version number; change this one line to update or roll back
Only caddy has portsThe app and database are not reachable from outside
CaddyAutomatically obtains and renews HTTPS certificates and redirects HTTP to HTTPS. See the final section of Nginx and reverse proxies
pgdata:/var/lib/postgresqlThe mount path for PostgreSQL 18. See PostgreSQL
pg_isready -h 127.0.0.1Prevents the database from being marked healthy while it is still initializing
restart: unless-stoppedStarts the service automatically after a crash or host reboot
loggingLimits 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 pull downloads all images first, including the myapp:1.0.0 image we pushed.
  • up -d starts services in dependency order: app starts after db becomes Healthy, followed by caddy.
  • In ps, only caddy exposes ports 80 and 443.
  • Two HTTPS requests changed visits from 1 to 2, and the data was written to PostgreSQL. (-k is 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 app downloads only the new image for app. (Because this simulation runs on one machine, the image is already local, so it takes only 0.1 seconds.)
  • up -d app detects the changed image and recreates only the app container. db and caddy are unaffected, and the site is interrupted for about one second.
  • The response's version is now 1.1.0, and visits continues from 3. The database data is preserved.
  • docker compose images app confirms that the current version is 1.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 -delete removes backups older than 14 days.
  • Decompress the file to check its contents: all 4 records in the visits table 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_VERSION on the server and run docker 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 ps

    docker --context prod compose up -d uses the compose.yaml in 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)
  • .env has 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 running up -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