Episode 6 — Scaling Reliability Microservices Web3 / 6.9 — Final Production Deployment

6.9.b -- EC2 and SSL

In one sentence: Deploy your Node.js application to an EC2 instance, manage it with PM2, point a custom domain via Route 53, and terminate HTTPS with either ACM (at the ALB) or Let's Encrypt (at the server) so every request is encrypted end-to-end.

Navigation: <-- 6.9.a Docker Deployment | 6.9.c -- CI/CD Pipelines -->


1. EC2 Instance Types for Node.js

1.1 Choosing the Right Instance

Node.js is single-threaded (event loop) but can utilise multiple cores via PM2 cluster mode. Memory is usually the bottleneck for API servers, not CPU.

Instance FamilyExamplevCPUsRAMBest ForMonthly Cost (approx.)
t3.microt3.micro21 GBFree tier, testing, tiny APIs~$8
t3.smallt3.small22 GBSmall production APIs~$16
t3.mediumt3.medium24 GBStandard production API server~$32
t3.larget3.large28 GBAPI with heavy memory usage~$64
m5.largem5.large28 GBConsistent performance (no burstable)~$72
m5.xlargem5.xlarge416 GBHigh-traffic APIs, multiple services~$144
c5.largec5.large24 GBCPU-bound workloads (image processing)~$64

t3 vs m5 decision:

t3 (burstable):
  + Cheaper baseline
  + Earns CPU credits when idle
  + Spends credits during bursts
  - Can be throttled if credits run out (unless "unlimited" mode)
  Best for: variable traffic, small-medium APIs

m5 (general purpose):
  + Consistent, predictable performance
  + No credit system
  - More expensive
  Best for: steady high-traffic APIs, production databases

2. Setting Up Node.js on EC2

2.1 Initial Server Setup

# SSH into your EC2 instance
ssh -i my-key.pem ec2-user@<EC2-PUBLIC-IP>

# Update the system
sudo yum update -y          # Amazon Linux 2
# OR
sudo apt update && sudo apt upgrade -y  # Ubuntu

# Install Node.js via nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm alias default 20

# Verify installation
node -v    # v20.x.x
npm -v     # 10.x.x

# Install build essentials (for native modules)
sudo yum groupinstall "Development Tools" -y   # Amazon Linux
# OR
sudo apt install build-essential -y             # Ubuntu

2.2 Deploy Your Application

# Option A: Clone from Git
git clone https://github.com/your-org/your-api.git /home/ec2-user/app
cd /home/ec2-user/app
npm ci --only=production
npm run build  # If using TypeScript

# Option B: Pull Docker image and run
docker pull 123456789.dkr.ecr.us-east-1.amazonaws.com/my-api:latest
docker run -d -p 3000:3000 --name my-api \
  --env-file /home/ec2-user/.env \
  123456789.dkr.ecr.us-east-1.amazonaws.com/my-api:latest

3. Process Management with PM2

PM2 is a production process manager for Node.js that provides clustering, auto-restart, log management, and zero-downtime reloads.

3.1 Install and Configure PM2

# Install PM2 globally
npm install -g pm2

# Create ecosystem config
cat > ecosystem.config.js << 'EOF'
module.exports = {
  apps: [{
    name: 'my-api',
    script: 'dist/index.js',
    instances: 'max',        // Use all available CPU cores
    exec_mode: 'cluster',    // Cluster mode for load balancing
    max_memory_restart: '300M',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    // Logging
    log_file: '/var/log/pm2/my-api.log',
    error_file: '/var/log/pm2/my-api-error.log',
    out_file: '/var/log/pm2/my-api-out.log',
    merge_logs: true,
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
    // Auto-restart
    watch: false,             // Never use watch in production
    max_restarts: 10,
    restart_delay: 4000,
    // Graceful shutdown
    kill_timeout: 5000,
    listen_timeout: 10000
  }]
};
EOF

3.2 PM2 Commands

# Start the application
pm2 start ecosystem.config.js

# View running processes
pm2 list

# Monitor (real-time dashboard)
pm2 monit

# View logs
pm2 logs my-api
pm2 logs my-api --lines 100

# Zero-downtime reload (cluster mode only)
pm2 reload my-api

# Restart (with brief downtime)
pm2 restart my-api

# Stop
pm2 stop my-api

# Auto-start PM2 on server reboot
pm2 startup systemd
pm2 save

3.3 PM2 with systemd (Alternative)

For servers not using PM2, you can manage Node.js with systemd directly:

# /etc/systemd/system/my-api.service
[Unit]
Description=My API Server
After=network.target

[Service]
Type=simple
User=ec2-user
WorkingDirectory=/home/ec2-user/app
ExecStart=/home/ec2-user/.nvm/versions/node/v20.11.1/bin/node dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/home/ec2-user/app/.env

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/log/my-api

[Install]
WantedBy=multi-user.target
# Enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable my-api
sudo systemctl start my-api
sudo systemctl status my-api

# View logs
sudo journalctl -u my-api -f

4. Connecting a Custom Domain

4.1 Route 53 -- AWS DNS Service

Route 53 manages your domain's DNS records. You can register a domain through Route 53 or transfer DNS management from another registrar.

How DNS resolution works:

User types: api.example.com
    |
    v
Browser asks DNS resolver --> "What is the IP of api.example.com?"
    |
    v
DNS resolver queries Route 53 hosted zone for example.com
    |
    v
Route 53 returns: A record --> 54.123.45.67 (your EC2 IP)
    |
    v
Browser connects to 54.123.45.67:443 (HTTPS)

4.2 DNS Record Types

Record TypePoints ToUse Case
AIPv4 address (e.g., 54.123.45.67)EC2 instance with Elastic IP
AAAAIPv6 addressIPv6-enabled resources
CNAMEAnother domain (e.g., my-alb.us-east-1.elb.amazonaws.com)ALB, CloudFront, other AWS services
Alias (Route 53 specific)AWS resourceALB, S3, CloudFront -- free, faster than CNAME

4.3 Setting Up DNS in Route 53

# Step 1: Create a hosted zone (if not already done)
aws route53 create-hosted-zone --name example.com --caller-reference $(date +%s)

# Step 2: Get your EC2 Elastic IP
aws ec2 allocate-address --domain vpc
aws ec2 associate-address --instance-id i-0abc123def456 --allocation-id eipalloc-0abc123

# Step 3: Create an A record
aws route53 change-resource-record-sets \
  --hosted-zone-id Z1234567890 \
  --change-batch '{
    "Changes": [{
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "api.example.com",
        "Type": "A",
        "TTL": 300,
        "ResourceRecords": [{"Value": "54.123.45.67"}]
      }
    }]
  }'

# Step 4: Verify DNS propagation
dig api.example.com
nslookup api.example.com

5. SSL/TLS Certificates

5.1 Why SSL/TLS is Non-Negotiable

Without SSL (HTTP):
  Browser <--plain text--> Server
  Anyone on the network can read passwords, tokens, personal data

With SSL (HTTPS):
  Browser <--encrypted--> Server
  Data is encrypted in transit -- only browser and server can read it

Also:
  - Google ranks HTTPS sites higher (SEO)
  - Browsers show "Not Secure" warnings for HTTP sites
  - Many APIs (geolocation, camera, service workers) REQUIRE HTTPS
  - HTTP/2 requires HTTPS in all major browsers

5.2 AWS Certificate Manager (ACM)

ACM provides free SSL certificates for AWS resources (ALB, CloudFront, API Gateway). Certificates auto-renew -- zero maintenance.

# Request a certificate for your domain
aws acm request-certificate \
  --domain-name api.example.com \
  --validation-method DNS \
  --subject-alternative-names "*.example.com"

# ACM outputs a CNAME record for validation
# Add this CNAME to Route 53 to prove you own the domain

# Validate via Route 53 (automated)
aws acm describe-certificate --certificate-arn arn:aws:acm:us-east-1:123456789:certificate/abc-123
# Wait for Status: ISSUED

# Attach to ALB listener
aws elbv2 create-listener \
  --load-balancer-arn arn:aws:elasticloadbalancing:us-east-1:123456789:loadbalancer/app/my-alb/abc123 \
  --protocol HTTPS \
  --port 443 \
  --certificates CertificateArn=arn:aws:acm:us-east-1:123456789:certificate/abc-123 \
  --default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:us-east-1:123456789:targetgroup/my-tg/abc123

5.3 Let's Encrypt (Free, Open-Source Alternative)

Use Let's Encrypt when you are not using an ALB -- e.g., SSL termination directly on the EC2 instance with Nginx.

# Install Certbot (Let's Encrypt client)
sudo yum install -y certbot python3-certbot-nginx    # Amazon Linux
# OR
sudo apt install -y certbot python3-certbot-nginx     # Ubuntu

# Obtain a certificate (Nginx plugin handles everything)
sudo certbot --nginx -d api.example.com -d www.example.com

# What certbot does:
# 1. Proves you control the domain (HTTP-01 challenge)
# 2. Obtains the certificate from Let's Encrypt
# 3. Configures Nginx to use the certificate
# 4. Sets up auto-renewal

# Verify auto-renewal is set up
sudo certbot renew --dry-run

# Certificate locations
# /etc/letsencrypt/live/api.example.com/fullchain.pem  (certificate + chain)
# /etc/letsencrypt/live/api.example.com/privkey.pem    (private key)

# Auto-renewal happens via systemd timer or cron:
sudo systemctl status certbot.timer

6. Nginx Reverse Proxy with HTTPS

Nginx sits between the internet and your Node.js application, handling SSL termination, static file serving, rate limiting, and request proxying.

6.1 Why Use a Reverse Proxy?

Without Nginx (Node.js directly exposed):
  Internet --> :3000 Node.js
  - Node.js handles SSL (not its strength)
  - Node.js serves static files (wasteful)
  - Single point of failure
  - No request buffering

With Nginx:
  Internet --> :443 Nginx --> :3000 Node.js
  - Nginx handles SSL (optimised in C)
  - Nginx serves static files (extremely fast)
  - Nginx buffers slow clients
  - Can load-balance to multiple Node.js instances
  - Nginx can serve cached responses

6.2 Complete Nginx Configuration

# /etc/nginx/conf.d/api.example.com.conf

# Redirect HTTP to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;

    # Let's Encrypt challenge (needed for certificate renewal)
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Redirect all other HTTP traffic to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS server
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name api.example.com;

    # SSL certificate (Let's Encrypt)
    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    # SSL configuration (Mozilla Intermediate)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # HSTS (tell browsers to always use HTTPS)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # Security headers
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Gzip compression
    gzip on;
    gzip_types text/plain application/json application/javascript text/css;
    gzip_min_length 1000;

    # Proxy to Node.js application
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        # WebSocket support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';

        # Forward real client info
        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;

        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        # Buffering
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    # Static files (serve directly, don't proxy to Node.js)
    location /static/ {
        alias /home/ec2-user/app/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Health check endpoint (bypass rate limiting if any)
    location /health {
        proxy_pass http://127.0.0.1:3000/health;
        access_log off;
    }

    # Deny access to hidden files
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # Access and error logs
    access_log /var/log/nginx/api.example.com.access.log;
    error_log  /var/log/nginx/api.example.com.error.log warn;
}

6.3 Install and Start Nginx

# Install Nginx
sudo yum install -y nginx    # Amazon Linux
# OR
sudo apt install -y nginx     # Ubuntu

# Test configuration syntax
sudo nginx -t

# Start and enable Nginx
sudo systemctl start nginx
sudo systemctl enable nginx

# Reload after config changes (no downtime)
sudo systemctl reload nginx

7. SSL Termination: ALB vs Server

There are two places to terminate SSL -- at the load balancer or at the server. Each approach has trade-offs.

Option A: SSL at ALB (recommended for AWS)
  Client --HTTPS--> ALB (terminates SSL) --HTTP--> EC2/ECS
  + ACM certificate (free, auto-renew)
  + Offloads CPU from application servers
  + Centralised certificate management
  - Traffic between ALB and server is HTTP (within VPC -- acceptable)

Option B: SSL at Server (Let's Encrypt + Nginx)
  Client --HTTPS--> Nginx on EC2 (terminates SSL) --HTTP--> Node.js
  + End-to-end encryption
  + Works without ALB
  + Good for single-server setups
  - Must manage certificate renewal
  - SSL uses server CPU

Option C: End-to-End SSL
  Client --HTTPS--> ALB --HTTPS--> Nginx on EC2 --HTTP--> Node.js
  + Maximum security (encrypted even within VPC)
  - Complexity of managing certificates in two places
  - Slight performance overhead

Recommendation:

SetupBest SSL Strategy
Single EC2 instanceLet's Encrypt + Nginx (Option B)
ALB + multiple instancesACM on ALB (Option A)
Compliance requires end-to-end encryptionACM on ALB + Let's Encrypt on server (Option C)

8. HTTP to HTTPS Redirect

Every production site must redirect HTTP to HTTPS. There are two levels.

8.1 Redirect at Nginx

server {
    listen 80;
    server_name api.example.com;
    return 301 https://$host$request_uri;
}

8.2 Redirect at ALB

# Create an HTTP listener that redirects to HTTPS
aws elbv2 create-listener \
  --load-balancer-arn arn:aws:elasticloadbalancing:... \
  --protocol HTTP \
  --port 80 \
  --default-actions '[{
    "Type": "redirect",
    "RedirectConfig": {
      "Protocol": "HTTPS",
      "Port": "443",
      "StatusCode": "HTTP_301"
    }
  }]'

8.3 Redirect in Express (Middleware Fallback)

// Only as a fallback -- Nginx or ALB should handle this
app.use((req, res, next) => {
  if (req.headers['x-forwarded-proto'] !== 'https') {
    return res.redirect(301, `https://${req.hostname}${req.url}`);
  }
  next();
});

9. Certificate Renewal

9.1 ACM (Automatic)

ACM certificates auto-renew as long as the DNS validation CNAME record exists. No action required.

9.2 Let's Encrypt (Semi-Automatic)

Certbot sets up auto-renewal, but you should verify it works.

# Check the auto-renewal timer
sudo systemctl status certbot.timer

# Test renewal (dry run -- does not actually renew)
sudo certbot renew --dry-run

# Manual renewal (if needed)
sudo certbot renew
sudo systemctl reload nginx

# Certificates expire after 90 days
# Certbot attempts renewal at 60 days
# If renewal fails, you get email warnings at 20 days and 7 days

10. Complete Setup Walkthrough: EC2 + Nginx + SSL + Domain

Here is the full sequence from a bare EC2 instance to a live, HTTPS-enabled API.

# =============================================
# Step 1: Launch EC2 Instance
# =============================================
# - Amazon Linux 2023 or Ubuntu 22.04
# - t3.medium (2 vCPU, 4 GB RAM)
# - Security Group: Allow ports 22 (SSH), 80 (HTTP), 443 (HTTPS)
# - Attach an Elastic IP

# =============================================
# Step 2: SSH and Install Dependencies
# =============================================
ssh -i my-key.pem ec2-user@54.123.45.67

sudo yum update -y
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
npm install -g pm2

sudo yum install -y nginx
sudo yum install -y certbot python3-certbot-nginx

# =============================================
# Step 3: Deploy Application
# =============================================
git clone https://github.com/your-org/your-api.git ~/app
cd ~/app
npm ci --only=production
npm run build

# =============================================
# Step 4: Start with PM2
# =============================================
pm2 start ecosystem.config.js
pm2 startup systemd
pm2 save

# Verify Node.js is running
curl http://localhost:3000/health

# =============================================
# Step 5: Configure DNS (Route 53 or registrar)
# =============================================
# Create A record: api.example.com --> 54.123.45.67
# Wait for propagation (usually < 5 minutes)
dig api.example.com   # Should return 54.123.45.67

# =============================================
# Step 6: Configure Nginx (without SSL first)
# =============================================
sudo tee /etc/nginx/conf.d/api.example.com.conf > /dev/null << 'NGINX'
server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1: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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
NGINX

sudo nginx -t && sudo systemctl restart nginx

# Verify HTTP works
curl http://api.example.com/health

# =============================================
# Step 7: Install SSL with Let's Encrypt
# =============================================
sudo certbot --nginx -d api.example.com

# Certbot will:
# 1. Obtain the certificate
# 2. Modify the Nginx config to add SSL
# 3. Add HTTP --> HTTPS redirect
# 4. Set up auto-renewal

# Verify HTTPS works
curl https://api.example.com/health

# =============================================
# Step 8: Verify Auto-Renewal
# =============================================
sudo certbot renew --dry-run

# DONE! Your API is live at https://api.example.com

11. Security Checklist for EC2

ItemHow
SSH key only (no passwords)Disable PasswordAuthentication in /etc/ssh/sshd_config
Security group: minimal portsOnly 22, 80, 443 inbound; restrict 22 to your IP
Elastic IPAttach so IP does not change on restart
Automatic updatessudo yum-cron or unattended-upgrades
Firewallufw or iptables as a second layer
Non-root Node.jsPM2 runs as ec2-user, not root
No secrets in codeUse environment files or AWS Secrets Manager
MonitoringCloudWatch agent for CPU, memory, disk

12. Key Takeaways

  1. t3.medium is the sweet spot for most Node.js APIs -- burstable, affordable, sufficient memory.
  2. PM2 cluster mode uses all CPU cores and provides zero-downtime reloads.
  3. Nginx reverse proxy handles SSL termination, compression, and static files so Node.js only handles API logic.
  4. Route 53 A records point your domain to EC2; CNAME/Alias records point to ALBs.
  5. ACM is free and auto-renews -- use it whenever you have an ALB or CloudFront.
  6. Let's Encrypt is the go-to for SSL directly on EC2 -- free, automated with Certbot.
  7. Always redirect HTTP to HTTPS -- at Nginx, ALB, or both.

Explain-It Challenge

  1. A teammate asks "do we need Nginx if we already have an ALB?" Explain when you would and would not use Nginx behind an ALB.
  2. Your SSL certificate expired and the site is down. Walk through how this happened and how to prevent it.
  3. Explain to a non-technical stakeholder why you need a custom domain and SSL certificate instead of just using the EC2 IP address.

Navigation: <-- 6.9.a Docker Deployment | 6.9.c -- CI/CD Pipelines -->