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
- A hardened Ubuntu 24.04 server (non-root sudo user, key-only SSH, firewall).
- Docker Engine + Compose plugin from Docker's official repo.
- A containerised Express app that restarts on reboot.
- Caddy in front of it, terminating TLS with an auto-renewing Let's Encrypt certificate.
Prerequisites
- An SSH key pair on your machine (
ssh-keygen -t ed25519if you don't have one). - A domain name you can add a DNS record to (needed for HTTPS in step 9).
- Basic terminal familiarity. No prior Docker knowledge required.
1. Create the server
Sign up for Hetzner Cloud and create a new Project. In the project:
- Security → SSH keys → Add SSH key. Paste the contents of your
~/.ssh/id_ed25519.pub. - Servers → Add Server. Choose: the location closest to your users (Nuremberg, Falkenstein, Helsinki, Ashburn, Hillsboro, Singapore); image Ubuntu 24.04; type
CX22(x86) orCAX11(Arm) — this tutorial builds the image on the server, so either works; the SSH key you just added; nameapp-01. - 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
rebootThe 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/deployOpen 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: root4. 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 sshOn 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 verbose6. 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-worldThe 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-hetznerpackage.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
Caddyfile8. 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 helloVisit 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 hello9. 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 exitWithin 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 imageUseful 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 snapshotWhere to go next
- Add a
deploystep to CI that SSHes in and runs the step 10 commands, or switch todocker contextand build locally. - Put a real database in its own service with a named volume, and take
hetznervolume snapshots on a schedule. - Set unattended-upgrades (
sudo dpkg-reconfigure -plow unattended-upgrades) so security patches land automatically.