Tools: Deploying Node.js with Docker + AWS EC2: A Complete Guide

Tools: Deploying Node.js with Docker + AWS EC2: A Complete Guide

Deploying Node.js with Docker + AWS EC2: A Complete Guide

What We're Building

The Dockerfile

.dockerignore

docker-compose for Local Development

AWS EC2 Setup

Instance Selection

Security Group Rules

EC2 Server Setup

Nginx Configuration

GitHub Actions CI/CD

Zero-Downtime Deployments

Environment Variables

Monitoring

EC2 Checklist Before Docker, deploying Node.js at IVTREE meant SSH-ing into the server, pulling from git, running npm install, and hoping the Node version matched production. It never fully matched. There was always something — a package that behaved differently, a native module that needed rebuilding, an environment variable that got missed. Docker eliminates all of that. What runs locally runs in production, every time. This is the exact setup I use for production Node.js deployments. By the end of this guide you'll have: Most Node.js Dockerfiles I see online are single-stage and install devDependencies in production. Here's the correct multi-stage approach: Why multi-stage? The builder stage has devDependencies, build tools, and compilation artifacts. The runner stage gets only what's needed to run. A typical Express API image goes from ~800MB (single-stage) to ~150MB (multi-stage). Why Alpine? node:20-alpine is 50MB vs node:20 at 400MB. For a Node.js API, you almost never need the full Debian image. Why non-root? If your container is ever compromised, running as root means the attacker has root access to the host via container escape vulnerabilities. Non-root limits the blast radius. Always add this — without it, Docker copies node_modules from your local machine into the build context, which takes forever and may include platform-specific binaries: The healthcheck on MongoDB ensures your API container only starts once MongoDB is actually ready to accept connections — not just running. Without this, you get race condition errors on startup. Never expose port 3000 publicly. Traffic goes through Nginx on 80/443, which proxies to your Node container on 3000 internally. Certbot automatically modifies your Nginx config to add HTTPS and sets up auto-renewal via a cron job. Store these in GitHub repository secrets: The --no-deps flag rebuilds only the api service without touching the database container. This is important — you don't want to restart MongoDB during a code deployment. The approach above has a brief gap when the old container stops and the new one starts. For true zero-downtime, use a blue-green approach with Nginx upstream switching: Overkill for most projects, but worth knowing for high-traffic APIs. Never put secrets in your Docker image. Use a .env file on the server: Reference it in docker-compose: The .env file on EC2 is separate from anything in your repository. Add .env to .gitignore and never commit secrets. Two simple things that catch most production issues: For production, set up log forwarding to CloudWatch or Datadog. But for early-stage products, docker compose logs piped to a file is often enough. Containers don't solve bad architecture — they just make deployment consistent. Make sure your app is well-structured before you containerize it. The most common mistake I see is treating Docker as a magic solution for messy code. It's not. A hard-coded port, an uncaught async error that crashes the process, a missing environment variable — all of these problems exist whether you're using Docker or not. Containerization is about consistency and reproducibility. Get the code right first. I'm Suyog Bhise, a Full Stack Developer at IVTREE where I manage Docker-based deployments to AWS EC2. suyogbhise.online Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# Stage 1: Build FROM node:20-alpine AS builder WORKDIR /app # Copy package files first — Docker caches this layer # Only re-runs -weight: 500;">npm ci when package*.json changes COPY package*.json ./ RUN -weight: 500;">npm ci --only=production # Stage 2: Run FROM node:20-alpine AS runner WORKDIR /app # Security: run as non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup # Copy only production dependencies from builder stage COPY --from=builder /app/node_modules ./node_modules # Copy application code COPY . . # Switch to non-root user before starting USER appuser EXPOSE 3000 CMD ["node", "server.js"] # Stage 1: Build FROM node:20-alpine AS builder WORKDIR /app # Copy package files first — Docker caches this layer # Only re-runs -weight: 500;">npm ci when package*.json changes COPY package*.json ./ RUN -weight: 500;">npm ci --only=production # Stage 2: Run FROM node:20-alpine AS runner WORKDIR /app # Security: run as non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup # Copy only production dependencies from builder stage COPY --from=builder /app/node_modules ./node_modules # Copy application code COPY . . # Switch to non-root user before starting USER appuser EXPOSE 3000 CMD ["node", "server.js"] # Stage 1: Build FROM node:20-alpine AS builder WORKDIR /app # Copy package files first — Docker caches this layer # Only re-runs -weight: 500;">npm ci when package*.json changes COPY package*.json ./ RUN -weight: 500;">npm ci --only=production # Stage 2: Run FROM node:20-alpine AS runner WORKDIR /app # Security: run as non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup # Copy only production dependencies from builder stage COPY --from=builder /app/node_modules ./node_modules # Copy application code COPY . . # Switch to non-root user before starting USER appuser EXPOSE 3000 CMD ["node", "server.js"] node_modules .-weight: 500;">git .gitignore *.md .env .env.* dist coverage .nyc_output logs *.log node_modules .-weight: 500;">git .gitignore *.md .env .env.* dist coverage .nyc_output logs *.log node_modules .-weight: 500;">git .gitignore *.md .env .env.* dist coverage .nyc_output logs *.log # -weight: 500;">docker-compose.yml version: '3.8' services: api: build: . ports: - "3000:3000" environment: - NODE_ENV=development - MONGO_URI=mongodb://mongo:27017/appdb - JWT_SECRET=local_dev_secret volumes: # Hot reload: mount source code - .:/app # Prevent local node_modules from overriding container's - /app/node_modules depends_on: mongo: condition: service_healthy -weight: 500;">restart: unless-stopped mongo: image: mongo:7 ports: - "27017:27017" volumes: - mongo_data:/data/db healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s timeout: 5s retries: 5 volumes: mongo_data: # -weight: 500;">docker-compose.yml version: '3.8' services: api: build: . ports: - "3000:3000" environment: - NODE_ENV=development - MONGO_URI=mongodb://mongo:27017/appdb - JWT_SECRET=local_dev_secret volumes: # Hot reload: mount source code - .:/app # Prevent local node_modules from overriding container's - /app/node_modules depends_on: mongo: condition: service_healthy -weight: 500;">restart: unless-stopped mongo: image: mongo:7 ports: - "27017:27017" volumes: - mongo_data:/data/db healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s timeout: 5s retries: 5 volumes: mongo_data: # -weight: 500;">docker-compose.yml version: '3.8' services: api: build: . ports: - "3000:3000" environment: - NODE_ENV=development - MONGO_URI=mongodb://mongo:27017/appdb - JWT_SECRET=local_dev_secret volumes: # Hot reload: mount source code - .:/app # Prevent local node_modules from overriding container's - /app/node_modules depends_on: mongo: condition: service_healthy -weight: 500;">restart: unless-stopped mongo: image: mongo:7 ports: - "27017:27017" volumes: - mongo_data:/data/db healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s timeout: 5s retries: 5 volumes: mongo_data: -weight: 500;">docker-compose up # foreground -weight: 500;">docker-compose up -d # background -weight: 500;">docker-compose down # -weight: 500;">stop and -weight: 500;">remove containers -weight: 500;">docker-compose down -v # also -weight: 500;">remove volumes (wipes database) -weight: 500;">docker-compose up # foreground -weight: 500;">docker-compose up -d # background -weight: 500;">docker-compose down # -weight: 500;">stop and -weight: 500;">remove containers -weight: 500;">docker-compose down -v # also -weight: 500;">remove volumes (wipes database) -weight: 500;">docker-compose up # foreground -weight: 500;">docker-compose up -d # background -weight: 500;">docker-compose down # -weight: 500;">stop and -weight: 500;">remove containers -weight: 500;">docker-compose down -v # also -weight: 500;">remove volumes (wipes database) Inbound: Port 22 (SSH) — Your IP only Port 80 (HTTP) — 0.0.0.0/0 Port 443 (HTTPS) — 0.0.0.0/0 Outbound: All traffic — 0.0.0.0/0 Inbound: Port 22 (SSH) — Your IP only Port 80 (HTTP) — 0.0.0.0/0 Port 443 (HTTPS) — 0.0.0.0/0 Outbound: All traffic — 0.0.0.0/0 Inbound: Port 22 (SSH) — Your IP only Port 80 (HTTP) — 0.0.0.0/0 Port 443 (HTTPS) — 0.0.0.0/0 Outbound: All traffic — 0.0.0.0/0 # Update system -weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">upgrade -y # Install Docker -weight: 500;">curl -fsSL https://get.-weight: 500;">docker.com -o get--weight: 500;">docker.sh -weight: 600;">sudo sh get--weight: 500;">docker.sh -weight: 600;">sudo usermod -aG -weight: 500;">docker ubuntu newgrp -weight: 500;">docker # Install Docker Compose -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -weight: 500;">docker-compose-plugin -y # Install Nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx -y # Install Certbot for SSL -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx -y # Create app directory mkdir -p /home/ubuntu/app # Update system -weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">upgrade -y # Install Docker -weight: 500;">curl -fsSL https://get.-weight: 500;">docker.com -o get--weight: 500;">docker.sh -weight: 600;">sudo sh get--weight: 500;">docker.sh -weight: 600;">sudo usermod -aG -weight: 500;">docker ubuntu newgrp -weight: 500;">docker # Install Docker Compose -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -weight: 500;">docker-compose-plugin -y # Install Nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx -y # Install Certbot for SSL -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx -y # Create app directory mkdir -p /home/ubuntu/app # Update system -weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">upgrade -y # Install Docker -weight: 500;">curl -fsSL https://get.-weight: 500;">docker.com -o get--weight: 500;">docker.sh -weight: 600;">sudo sh get--weight: 500;">docker.sh -weight: 600;">sudo usermod -aG -weight: 500;">docker ubuntu newgrp -weight: 500;">docker # Install Docker Compose -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -weight: 500;">docker-compose-plugin -y # Install Nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx -y # Install Certbot for SSL -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx -y # Create app directory mkdir -p /home/ubuntu/app # /etc/nginx/sites-available/api server { listen 80; server_name api.yourdomain.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection '-weight: 500;">upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } # /etc/nginx/sites-available/api server { listen 80; server_name api.yourdomain.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection '-weight: 500;">upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } # /etc/nginx/sites-available/api server { listen 80; server_name api.yourdomain.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection '-weight: 500;">upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } # Enable the site -weight: 600;">sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/ -weight: 600;">sudo nginx -t -weight: 600;">sudo -weight: 500;">systemctl reload nginx # Get SSL certificate (replace with your domain) -weight: 600;">sudo certbot --nginx -d api.yourdomain.com # Enable the site -weight: 600;">sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/ -weight: 600;">sudo nginx -t -weight: 600;">sudo -weight: 500;">systemctl reload nginx # Get SSL certificate (replace with your domain) -weight: 600;">sudo certbot --nginx -d api.yourdomain.com # Enable the site -weight: 600;">sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/ -weight: 600;">sudo nginx -t -weight: 600;">sudo -weight: 500;">systemctl reload nginx # Get SSL certificate (replace with your domain) -weight: 600;">sudo certbot --nginx -d api.yourdomain.com # .github/workflows/deploy.yml name: Deploy to EC2 on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to EC2 uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.EC2_HOST }} username: ubuntu key: ${{ secrets.EC2_SSH_KEY }} script: | cd /home/ubuntu/app # Pull latest code -weight: 500;">git pull origin main # Build new image -weight: 500;">docker build -t myapp:latest . # Zero-downtime: -weight: 500;">start new container, -weight: 500;">stop old one -weight: 500;">docker compose up -d --build --no-deps api # Clean up old images -weight: 500;">docker image prune -f # .github/workflows/deploy.yml name: Deploy to EC2 on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to EC2 uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.EC2_HOST }} username: ubuntu key: ${{ secrets.EC2_SSH_KEY }} script: | cd /home/ubuntu/app # Pull latest code -weight: 500;">git pull origin main # Build new image -weight: 500;">docker build -t myapp:latest . # Zero-downtime: -weight: 500;">start new container, -weight: 500;">stop old one -weight: 500;">docker compose up -d --build --no-deps api # Clean up old images -weight: 500;">docker image prune -f # .github/workflows/deploy.yml name: Deploy to EC2 on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to EC2 uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.EC2_HOST }} username: ubuntu key: ${{ secrets.EC2_SSH_KEY }} script: | cd /home/ubuntu/app # Pull latest code -weight: 500;">git pull origin main # Build new image -weight: 500;">docker build -t myapp:latest . # Zero-downtime: -weight: 500;">start new container, -weight: 500;">stop old one -weight: 500;">docker compose up -d --build --no-deps api # Clean up old images -weight: 500;">docker image prune -f # -weight: 500;">docker-compose.yml — two app instances services: api-blue: build: . ports: - "3001:3000" api-green: build: . ports: - "3002:3000" # -weight: 500;">docker-compose.yml — two app instances services: api-blue: build: . ports: - "3001:3000" api-green: build: . ports: - "3002:3000" # -weight: 500;">docker-compose.yml — two app instances services: api-blue: build: . ports: - "3001:3000" api-green: build: . ports: - "3002:3000" # deploy.sh on EC2 #!/bin/bash # Determine current active color ACTIVE=$(cat /tmp/active_color 2>/dev/null || echo "blue") NEXT=$([ "$ACTIVE" = "blue" ] && echo "green" || echo "blue") # Start new version -weight: 500;">docker compose up -d --build api-$NEXT # Wait for health check sleep 10 # Switch Nginx to new version PORT=$([ "$NEXT" = "blue" ] && echo "3001" || echo "3002") sed -i "s/proxy_pass http:\/\/localhost:[0-9]*/proxy_pass http:\/\/localhost:$PORT/" /etc/nginx/sites-available/api nginx -s reload # Stop old version -weight: 500;">docker compose -weight: 500;">stop api-$ACTIVE # Record active color echo $NEXT > /tmp/active_color # deploy.sh on EC2 #!/bin/bash # Determine current active color ACTIVE=$(cat /tmp/active_color 2>/dev/null || echo "blue") NEXT=$([ "$ACTIVE" = "blue" ] && echo "green" || echo "blue") # Start new version -weight: 500;">docker compose up -d --build api-$NEXT # Wait for health check sleep 10 # Switch Nginx to new version PORT=$([ "$NEXT" = "blue" ] && echo "3001" || echo "3002") sed -i "s/proxy_pass http:\/\/localhost:[0-9]*/proxy_pass http:\/\/localhost:$PORT/" /etc/nginx/sites-available/api nginx -s reload # Stop old version -weight: 500;">docker compose -weight: 500;">stop api-$ACTIVE # Record active color echo $NEXT > /tmp/active_color # deploy.sh on EC2 #!/bin/bash # Determine current active color ACTIVE=$(cat /tmp/active_color 2>/dev/null || echo "blue") NEXT=$([ "$ACTIVE" = "blue" ] && echo "green" || echo "blue") # Start new version -weight: 500;">docker compose up -d --build api-$NEXT # Wait for health check sleep 10 # Switch Nginx to new version PORT=$([ "$NEXT" = "blue" ] && echo "3001" || echo "3002") sed -i "s/proxy_pass http:\/\/localhost:[0-9]*/proxy_pass http:\/\/localhost:$PORT/" /etc/nginx/sites-available/api nginx -s reload # Stop old version -weight: 500;">docker compose -weight: 500;">stop api-$ACTIVE # Record active color echo $NEXT > /tmp/active_color # /home/ubuntu/app/.env (on EC2, not in -weight: 500;">git) NODE_ENV=production MONGO_URI=mongodb://mongo:27017/appdb JWT_SECRET=your_actual_secret_here STRIPE_SECRET_KEY=sk_live_... # /home/ubuntu/app/.env (on EC2, not in -weight: 500;">git) NODE_ENV=production MONGO_URI=mongodb://mongo:27017/appdb JWT_SECRET=your_actual_secret_here STRIPE_SECRET_KEY=sk_live_... # /home/ubuntu/app/.env (on EC2, not in -weight: 500;">git) NODE_ENV=production MONGO_URI=mongodb://mongo:27017/appdb JWT_SECRET=your_actual_secret_here STRIPE_SECRET_KEY=sk_live_... services: api: env_file: - .env services: api: env_file: - .env services: api: env_file: - .env # View live logs -weight: 500;">docker compose logs -f api # Container resource usage -weight: 500;">docker stats # Check if containers are running -weight: 500;">docker compose ps # View live logs -weight: 500;">docker compose logs -f api # Container resource usage -weight: 500;">docker stats # Check if containers are running -weight: 500;">docker compose ps # View live logs -weight: 500;">docker compose logs -f api # Container resource usage -weight: 500;">docker stats # Check if containers are running -weight: 500;">docker compose ps - A multi-stage Docker build that produces a small, secure image - -weight: 500;">docker-compose for local development with MongoDB included - GitHub Actions CI/CD that deploys to EC2 on every push to main - Nginx as a reverse proxy with SSL via Let's Encrypt - Zero-downtime deployments - t3.small (2GB RAM) — minimum for a real Node.js API - t3.medium (4GB RAM) — recommended for APIs with meaningful traffic - t2.micro — the free tier option, too slow for production under any real load - EC2_HOST — your EC2 public IP - EC2_SSH_KEY — your EC2 private key (the .pem file contents) - [ ] Elastic IP assigned (so IP doesn't change on -weight: 500;">restart) - [ ] Domain pointing to Elastic IP - [ ] SSL certificate installed (Certbot) - [ ] Security group only exposes 80, 443, 22 - [ ] .env file on server with production secrets - [ ] -weight: 500;">docker-compose -weight: 500;">restart: unless-stopped set on all services - [ ] GitHub Actions secrets set - [ ] First deployment tested manually