How to Deploy a Node.js App to a VPS with Docker (2025 Guide)
A practical walkthrough: Dockerfile, reverse proxy, SSL, and how to redeploy without downtime. Works with Express, Fastify, NestJS, and Next.js.
Deploying a Node.js app to a VPS is one of the most common tasks in backend development — and one of the most error-prone when done manually. This guide walks you through the complete workflow: Dockerfile, environment variables, reverse proxy, HTTPS, and zero-downtime redeploys.
Prerequisites
- A Linux VPS (Ubuntu 22.04 recommended) with at least 1 GB RAM
- Node.js app with a start script in package.json
- Docker installed on the VPS
- A domain name pointed at your VPS IP (optional but needed for HTTPS)
Step 1: Write a Production Dockerfile
A well-structured Dockerfile is critical for reliable deployments. Here's a production-ready Dockerfile for a Node.js app:
FROM node:20-alpine
WORKDIR /app
# Install dependencies first (layer caching — only re-runs if lockfile changes)
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Build step (TypeScript, bundlers, etc.)
RUN npm run build 2>/dev/null || true
EXPOSE 3000
CMD ["node", "dist/index.js"]The key trick is copying package.json before the rest of your source code. Docker caches each layer; if your code changes but your dependencies didn't, it skips the npm install layer — builds go from 3 minutes to 15 seconds.
Step 2: Build and Run the Container
# Build the image
docker build -t myapp:latest .
# Run with environment variables
docker run -d \
--name myapp \
--restart unless-stopped \
-p 3000:3000 \
-e DATABASE_URL="postgres://..." \
-e NODE_ENV=production \
myapp:latestThe --restart unless-stopped flag means Docker will automatically restart your container if it crashes or if the VPS reboots. This is your basic process supervision without needing systemd or pm2.
Step 3: Set Up a Reverse Proxy with HTTPS
Running Node.js directly on port 80/443 as root is a security risk. Instead, run Nginx as a reverse proxy on port 443 and forward traffic to your Node.js container on port 3000.
server {
listen 443 ssl;
server_name api.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
}Get a free TLS certificate with Certbot: sudo certbot --nginx -d api.yourdomain.com. Certbot auto-renews every 90 days.
Step 4: Zero-Downtime Redeploys
The naive approach — docker stop → docker build → docker run — causes downtime. Here's a zero-downtime blue-green pattern:
# Build the new image
docker build -t myapp:next .
# Start the new container on a different port
docker run -d --name myapp-next -p 3001:3000 myapp:next
# Wait for it to be healthy
sleep 5
# Swap (stop old, rename new to take its place)
docker stop myapp && docker rm myapp
docker rename myapp-next myappTip: Platforms like Deployzy handle blue-green deploys automatically. When you push to GitHub, it builds a new container, waits for it to pass a health check, then atomically swaps it — no manual steps and no downtime.
Step 5: Environment Variable Management
Never hard-code secrets in your Docker image. Three options:
- 1.-e KEY=value flags on docker run (fine for simple apps)
- 2.--env-file .env flag pointing at a file on the host (keep it outside the repo)
- 3.Docker secrets or a secrets manager (Vault, AWS Secrets Manager) for production
Skip the Manual Setup with Deployzy
All of the above is automated in Deployzy. Connect your GitHub repo, set your environment variables in the dashboard, and Deployzy handles the Dockerfile generation (or uses yours), builds the image, sets up HTTPS automatically, and does zero-downtime blue-green redeploys on every push to main.
Connect your GitHub repo and go live — no Nginx config, no Certbot commands.
Start deploying free