Containerizing a Node.js Application with Docker on a VPS: Step-by-Step Tutorial

Running a Node.js application directly on a VPS works — until you need to manage dependencies, runtime versions, environment variables, and process restart behavior across deploys. Docker solves all of these problems by packaging your application, its dependencies, and its runtime into a single portable container. This tutorial walks through containerizing a Node.js application, deploying it to a VPS, and setting up production-grade process management with Docker Compose.

Prerequisites

  • A Linux VPS with Ubuntu 22.04 or 24.04 (2 GB RAM minimum)
  • Docker and Docker Compose installed
  • A Node.js application (Express, Fastify, Koa, or any framework — we’ll use a simple Express app as the example)
  • Git access to your application repository

If you need a VPS to deploy this on, check our VPS comparison table to find a provider with Docker-friendly plans and fast NVMe storage.

Step 1: Install Docker on Your VPS

If Docker is not already installed, use the official convenience script:

# Install Docker using the official script
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Add your user to the docker group (so you don't need sudo for every command)
sudo usermod -aG docker $USER

# Log out and back in, or run:
newgrp docker

# Verify installation
docker --version
# Expected: Docker version 27.x.x

Install Docker Compose (if not included with your Docker installation):

sudo apt install docker-compose-plugin -y
# Or download the standalone binary:
# sudo curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# sudo chmod +x /usr/local/bin/docker-compose

Step 2: Create a Dockerfile for Your Node.js App

A Dockerfile defines how your application is built and run. Create this file in the root of your Node.js project:

# Use the official Node.js 20 LTS image as the base
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy package.json and package-lock.json first (for better layer caching)
COPY package*.json ./

# Install production dependencies only
RUN npm ci --only=production && npm cache clean --force

# Copy the rest of the application source code
COPY . .

# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Expose the port your app listens on
EXPOSE 3000

# Start the application
CMD ["node", "src/index.js"]

Key points about this Dockerfile:

  • FROM node:20-alpine — Alpine-based images are ~50 MB smaller than full Debian images, reducing download and deployment time.
  • COPY package*.json first — Docker caches layers. If you change source code but not dependencies, Docker reuses the cached npm ci layer.
  • npm ci — Uses package-lock.json for deterministic installs. Faster than npm install in CI/CD.
  • Non-root user — Running as a non-root user inside the container is a security best practice.

Step 3: Create a .dockerignore File

Create a .dockerignore file in your project root to exclude unnecessary files from the Docker build context:

node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
README.md

This prevents node_modules (which will be rebuilt inside the container) and sensitive files like .env from being copied into the Docker image.

Step 4: Create a Docker Compose File

Docker Compose orchestrates multi-container setups. Create a docker-compose.yml file:

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: my-node-app
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Key configuration points:

  • restart: unless-stopped — Automatically restarts the container if it crashes or the VPS reboots.
  • healthcheck — Docker periodically checks if the app is responding. Unhealthy containers can be replaced automatically.
  • logging — Limits log file size to prevent disk exhaustion. A common issue on small VPS disks.

Step 5: Build and Run the Container

# Build the Docker image
cd /path/to/your/nodejs-app
docker compose build

# Start the container in detached mode
docker compose up -d

# Check container status
docker compose ps

# View logs
docker compose logs -f

Your application should now be accessible at http://your-vps-ip:3000. If you have a reverse proxy (Nginx or Caddy) already running, configure it to proxy requests to localhost:3000.

Step 6: Add a Reverse Proxy (Nginx + Let’s Encrypt)

For production, you should serve your application through a reverse proxy with TLS. Add a reverse proxy service to your docker-compose.yml:

services:
  app:
    # ... (same as above)

  nginx:
    image: nginx:alpine
    container_name: nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
      - certbot-www:/var/www/certbot
    depends_on:
      - app
    restart: unless-stopped

  certbot:
    image: certbot/certbot
    container_name: certbot
    volumes:
      - ./ssl:/etc/letsencrypt
      - certbot-www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h; done'"

volumes:
  certbot-www:

For a simpler approach, use Caddy, which handles TLS automatically:

services:
  app:
    # ... (same as above)

  caddy:
    image: caddy:alpine
    container_name: caddy-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
    restart: unless-stopped

volumes:
  caddy-data:

Step 7: Manage Environment Variables

Never hardcode secrets in your Dockerfile. Create a .env file in your project root (add it to .gitignore!):

# .env file
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@db:5432/myapp
REDIS_URL=redis://redis:6379
JWT_SECRET=your-production-secret-here

Docker Compose automatically reads the .env file and passes the variables to the container. For production secrets, consider using Docker secrets or a vault solution.

Step 8: Deploy Updates

When you push new code to your repository, deploy the update on your VPS:

# Pull the latest code
cd /path/to/your/nodejs-app
git pull origin main

# Rebuild and restart with zero downtime (if using --scale)
docker compose build
docker compose up -d --no-deps

# Remove old images to free disk space
docker image prune -f

Step 9: Monitor and Troubleshoot

Essential Docker commands for ongoing management:

# Check resource usage
docker stats

# View real-time logs
docker compose logs -f --tail=50

# Execute commands inside the container
docker exec -it my-node-app sh

# Check disk usage of Docker
docker system df

Common issues and fixes:

  • Container exits immediately: Run docker compose logs to see the error. Usually a missing package.json or a syntax error in the app.
  • Port already in use: Something else is listening on port 3000. Stop the other process or change the port mapping.
  • Out of disk space: Run docker system prune -a to remove unused images, containers, and build cache.
  • Permissions denied: Ensure your user is in the docker group. Check with groups $USER.

Production Checklist

  • Use a non-root user inside the container
  • Set memory limits: deploy: resources: limits: memory: 512M
  • Enable Docker’s restart policy: restart: unless-stopped
  • Configure log rotation to prevent disk exhaustion
  • Use a reverse proxy with TLS (Nginx, Caddy, or Traefik)
  • Set up a health check endpoint in your application
  • Run your database in a separate container or use a managed database provider

Containerizing your Node.js application with Docker on a VPS gives you reproducible deployments, easy rollbacks, and consistent environments across development and production. For more VPS deployment guides and provider recommendations, visit the main site.

Leave a Reply