← All posts

Deploy a Node.js app on a Hetzner VPS with Docker

Hetzner Cloud is my default for small-to-medium production boxes: an Arm CAX11 (2 vCPU, 4 GB RAM, 40 GB NVMe) is about €3.29/month and an x86 CX22 with the same specs is about €3.79/month, both on fast NVMe. This walkthrough takes a fresh server to a running, HTTPS-served Node.js app. Every command here was run on a clean Ubuntu 24.04 CX22. If you're still choosing a provider, my DigitalOcean vs Vultr comparison covers how the mainstream options stack up.

What you'll end up with

Prerequisites

1. Create the server

Sign up for Hetzner Cloud and create a new Project. In the project:

  1. Security → SSH keys → Add SSH key. Paste the contents of your ~/.ssh/id_ed25519.pub.
  2. Servers → Add Server. Choose: the location closest to your users (Nuremberg, Falkenstein, Helsinki, Ashburn, Hillsboro, Singapore); image Ubuntu 24.04; type CX22 (x86) or CAX11 (Arm) — this tutorial builds the image on the server, so either works; the SSH key you just added; name app-01.
  3. Create it, then copy the server's public IPv4 address.

Optional but recommended: under Firewalls, create a Hetzner Cloud firewall allowing inbound TCP 22, 80 and 443 only, and attach it to the server. That's a second layer in front of the host firewall we set up in step 5.

2. First login and system update

Connect as root using the key Hetzner installed:

ssh root@YOUR_SERVER_IP

apt update && apt upgrade -y
apt install -y ca-certificates curl ufw rsync
reboot

The reboot picks up any new kernel. Wait ~20 seconds and SSH back in.

3. Create a non-root user

Running containers and everyday work as root is unnecessary risk. Create a user with sudo:

adduser deploy            # set a password, accept the defaults
usermod -aG sudo deploy

# copy your authorised key so you can SSH in directly as 'deploy'
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

Open a new terminal (keep the root session open as a safety net) and confirm the new user works:

ssh deploy@YOUR_SERVER_IP
sudo whoami        # should print: root

4. Harden SSH

Disable root login and password authentication. Put the overrides in a drop-in file so a future openssh-server upgrade doesn't clobber them:

sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF

sudo systemctl restart ssh

On Ubuntu 24.04 SSH is socket-activated; if the restart above has no effect, run sudo systemctl restart ssh.socket. Test in a new terminal before closing your working session: ssh deploy@YOUR_SERVER_IP should still work, and ssh root@YOUR_SERVER_IP should now be refused.

5. Host firewall

Allow SSH, HTTP and HTTPS; deny everything else inbound:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

6. Install Docker

Use Docker's official APT repository, not the older distro package:

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

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

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

# run docker without sudo
sudo usermod -aG docker $USER
newgrp docker      # or log out and back in

docker run --rm hello-world

The hello-world container printing a success message means the engine is up.

7. The application

Create the project on the server (or build it locally and git clone / scp it up). It's a minimal Express API with a health check.

mkdir -p ~/hello-hetzner && cd ~/hello-hetzner

package.json

{
  "name": "hello-hetzner",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": { "start": "node app.js" },
  "dependencies": { "express": "^4.21.2" }
}

app.js

import express from "express";

const app = express();
const port = process.env.PORT || 3000;

app.get("/", (_req, res) => {
  res.json({
    status: "ok",
    host: process.env.HOSTNAME ?? null,
    time: new Date().toISOString(),
  });
});

app.get("/healthz", (_req, res) => res.type("text").send("ok"));

app.listen(port, () => console.log("listening on :" + port));

Dockerfile — multi-stage so the final image carries only production dependencies, and it runs as the built-in non-root node user.

# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "app.js"]

npm ci needs a lockfile. Generate one once (locally or on the server) with npm install, which creates package-lock.json. Then add .dockerignore:

node_modules
.git
.gitignore
Dockerfile
.dockerignore
*.md
compose.yaml
Caddyfile

8. Build and run (HTTP)

Quick check that the container works before adding TLS:

docker build -t hello-hetzner .
docker run -d --name hello --restart unless-stopped -p 80:3000 hello-hetzner

curl -s http://localhost | head
docker logs hello

Visit http://YOUR_SERVER_IP in a browser — you should get the JSON payload. Then stop it, because step 9 needs port 80:

docker rm -f hello

9. HTTPS with Caddy

Point DNS at the server first: create an A record for your domain (say app.example.com) to YOUR_SERVER_IP and wait for it to resolve (dig +short app.example.com). Caddy needs this to pass the Let's Encrypt challenge.

compose.yaml

services:
  app:
    build: .
    restart: unless-stopped
    environment:
      - NODE_ENV=production
    expose:
      - "3000"          # visible to caddy only, not published to the host

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app

volumes:
  caddy_data:
  caddy_config:

Caddyfile — replace the domain and the email:

app.example.com {
    encode gzip
    reverse_proxy app:3000
}

Bring it all up:

docker compose up -d --build
docker compose ps
docker compose logs -f caddy      # watch it obtain the certificate, Ctrl-C to exit

Within a few seconds Caddy fetches and installs the certificate. Load https://app.example.com — valid padlock, JSON response, and plain http:// now redirects to https://. Renewal is automatic.

10. Deploying updates

Change your code, then rebuild and roll the containers:

cd ~/hello-hetzner
git pull                       # or scp the changed files up

docker compose up -d --build   # rebuilds 'app', recreates only what changed
docker image prune -f          # drop the now-dangling old image

Useful day-to-day commands:

docker compose logs -f app     # tail application logs
docker compose restart app     # restart without rebuilding
docker compose down            # stop everything (data volumes are kept)
docker stats --no-stream       # quick CPU / memory snapshot

Where to go next