DevOps for Beginner

// table of contents

Comprehensive Web App Deployment Guide (Beginner to Pro) - Detailed Example


1. Introduction

This guide aims to provide a clear, step-by-step process for deploying a modern web application, specifically focusing on a Next.js frontend (capable of static, SSR, and API routes) with a Node.js/Express backend, backed by PostgreSQL. We’ll start with a single server setup on a Linode or DigitalOcean VPS, integrate Cloudflare as a free CDN, and then discuss scaling with a load balancer. The guide is designed for beginners to follow, while offering depth for experienced developers.

Architecture Overview (Single Server)

This diagram illustrates how user requests flow from their browser, through Cloudflare’s CDN, to your single VPS, where Nginx acts as a reverse proxy for your Node.js application and serves static assets. Your Node.js application then communicates with a PostgreSQL database (either managed or self-hosted on the same server).

+-----------------+        +-----------------+
|   User Browser  | <----> |    Cloudflare   |
|                 |        |      (CDN)      |
+-----------------+        +-----------------+
         |                          ^
         | HTTP/S                   |
         v                          |
+---------------------------------+ |
|           VPS (Ubuntu)          | |
| +-----------------------------+ | |
| |           Nginx             | | |
| | (Reverse Proxy, Static)     |<--- Static Assets (from Next.js build)
| |          ^   |              | |
| |          |   |              | |
| | HTTP/S   |   +------------->| |
| | (API/SSR)|                  | |
| |          v                  | |
| |   +---------------------+   | |
| |   | Node.js App (PM2)   |   | |
| |   | (Next.js & Express) |   | |
| |   +---------------------+   | |
| +-----------------------------+ |
|                 |               |
|                 | Database Conn.|
|                 v               |
|   +-------------------------+   |
|   | PostgreSQL DB (Managed) |   |
|   +-------------------------+   |
+---------------------------------+

Architecture Overview (Load Balanced)

For higher traffic or redundancy, a load-balanced setup distributes incoming requests across multiple VPS instances. Each VPS runs an identical copy of your application, and they all connect to a single, usually managed, database. Cloudflare still sits in front for CDN and DDoS protection.

+-----------------+        +-----------------+
|   User Browser  | <----> |    Cloudflare   |
|                 |        |      (CDN)      |
+-----------------+        +-----------------+
         |                          ^
         | HTTP/S                   |
         v                          |
+---------------------------------+ |
|    Linode/DigitalOcean LB       | |
|      (Public IP, SSL)           | |
+---------------------------------+ |
         |                          |
         | Internal HTTP/S          |
         v                          |
+---------------------+    +---------------------+
|      VPS 1          |    |      VPS 2          |
| +-----------------+ |    | +-----------------+ |
| |      Nginx      | |    | |      Nginx      | |
| |       ^   |     | |    | |       ^   |     | |
| |       |   |     | |    | |       |   |     | |
| |       v   |     | |    | |       v   |     | |
| | Node.js App   | |    | | Node.js App   | |
| |    (PM2)      | |    | |    (PM2)      | |
| +-----------------+ |    | +-----------------+ |
|           |         |    |           |         |
|           v         |    |           v         |
|   +-----------------+     |   +-----------------+
|   | Internal Network  |   |   | Internal Network  |
|   |     (VPC)         |<----->|     (VPC)         |
|   +-----------------+     |   +-----------------+
+---------------------+     +---------------------+
         |
         | Database Conn.
         v
+-------------------------+
| Managed PostgreSQL DB   |
+-------------------------+

2. Domain Purchase & DNS Setup

Choosing a Registrar

Popular choices include:

  • Namecheap: Affordable, good UI.
  • Cloudflare: Excellent integration with their CDN and DNS, sometimes offers free domain registration with certain plans.
  • Google Domains: Simple, integrated with Google ecosystem. 💡 Tip: While you can buy a domain from any registrar, consider transferring DNS management to Cloudflare later for its powerful features.

DNS Records Explained (A, CNAME, MX, TXT)

  • A Record (Address Record): Maps a domain name (or subdomain) to an IPv4 address. Essential for pointing your domain to your VPS.
  • CNAME Record (Canonical Name Record): Maps an alias name to another canonical domain name. Used for www to point to your naked domain.
  • MX Record (Mail Exchange Record): Specifies mail servers for your domain. (Not directly covered in this web app deployment, but good to know).
  • TXT Record (Text Record): Stores arbitrary text, often used for verification (e.g., SSL certificate validation, email authentication).

Configuring DNS for your App

Once you have your domain and VPS IP address:

  1. Log in to your domain registrar’s DNS management panel.
  2. Add an A record:
    • Host/Name: @ (or leave blank for the naked domain, e.g., yourdomain.com)
    • Value/Points to: Your VPS’s Public IPv4 Address
  3. Add another A record (optional, for subdomains):
    • Host/Name: api (if your API is on api.yourdomain.com)
    • Value/Points to: Your VPS’s Public IPv4 Address
  4. Add a CNAME record for www:
    • Host/Name: www
    • Value/Points to: yourdomain.com (or @) ⚠ Important: Record names vary slightly between registrars. Consult their documentation if unsure.

Checking DNS Propagation

DNS changes don’t happen instantly. It can take minutes to hours (up to 48 hours, though usually much faster). You can check propagation using:

  • dig yourdomain.com in your terminal.
  • Online tools like whatsmydns.net. Look for green checkmarks across different locations.

3. VPS Provisioning (Linode/DigitalOcean)

Choosing VPS Specs (CPU, RAM, Storage, Bandwidth)

For a beginner setup, optimize for cost.

  • Low Traffic (Personal Project/Testing):
    • CPU: 1 Core
    • RAM: 1-2 GB
    • Storage (SSD): 25-50 GB
    • Bandwidth: 1-2 TB/month
    • Examples: Linode Nanode 1GB ($5/month) or DigitalOcean Basic Droplet 1GB ($6/month).
  • Medium Traffic:
    • CPU: 2-4 Cores
    • RAM: 4-8 GB
    • Storage (SSD): 80-160 GB
    • Bandwidth: 4-6 TB/month

Operating System Selection (Ubuntu 22.04/24.04 LTS)

Ubuntu LTS (Long Term Support) versions are recommended for stability and long-term support, receiving updates for several years.

  • Ubuntu 22.04 LTS (Jammy Jellyfish): Very stable, widely supported.
  • Ubuntu 24.04 LTS (Noble Numbat): The latest LTS, potentially with newer package versions, but still relatively fresh. 💡 Tip: For this guide, commands will primarily target Ubuntu 22.04/24.04.

Creating SSH Keys

SSH keys provide a more secure way to log into your server than passwords.

  1. Generate a new SSH key pair on your local machine:
    ssh-keygen -t ed25519 -C "your_email@example.com"
    
    • Press Enter for the default file location (~/.ssh/id_ed25519).
    • Enter a strong passphrase when prompted. This protects your private key.
  2. View your public key:
    cat ~/.ssh/id_ed25519.pub
    
    Copy the entire output (starts with ssh-ed25519 and ends with your comment).

Launching Your VPS (Linode/DigitalOcean)

Linode:

  1. Log in to your Linode Cloud Manager.
  2. Click “Create Linode”.
  3. Choose your Distribution (Ubuntu 22.04 LTS or 24.04 LTS).
  4. Select your Region (choose one geographically close to your users).
  5. Choose your Linode Plan (e.g., Nanode 1GB).
  6. Under Add SSH Keys, paste the public key you copied earlier.
  7. Set a Root Password (as a fallback, though you’ll primarily use SSH keys).
  8. Click “Create Linode”.
  9. Note down the Linode’s Public IPv4 Address once it’s provisioned.

DigitalOcean:

  1. Log in to your DigitalOcean Control Panel.
  2. Click “Create” -> “Droplets”.
  3. Choose an Image (Ubuntu 22.04 LTS or 24.04 LTS).
  4. Select a Region.
  5. Choose a Droplet Plan (e.g., Basic, 1GB RAM).
  6. Under Authentication, select “SSH keys”. Add your public SSH key if you haven’t already.
  7. Click “Create Droplet”.
  8. Note down the Droplet’s Public IPv4 Address once it’s provisioned.

After provisioning, you can connect to your server:

ssh root@YOUR_VPS_IP_ADDRESS

You might be prompted to accept the host key, type yes. If you set a passphrase for your SSH key, enter it.

4. Server Security Fundamentals

Securing your server is paramount from day one.

Initial SSH Hardening (Port, Root Login, Key-based Auth)

We will create a new user and disable direct root login via SSH for better security.

  1. Create a new non-root user:

    adduser youruser # Replace 'youruser' with your desired username
    usermod -aG sudo youruser
    
    • Set a strong password for this user when prompted.
    • The usermod command adds the user to the sudo group, allowing them to run commands with superuser privileges.
  2. Copy SSH keys to the new user: While still logged in as root, copy your SSH public key to the new user’s authorized keys file.

    mkdir -p /home/youruser/.ssh
    cp ~/.ssh/authorized_keys /home/youruser/.ssh/
    chown -R youruser:youruser /home/youruser/.ssh
    chmod 700 /home/youruser/.ssh
    chmod 600 /home/youruser/.ssh/authorized_keys
    
  3. Configure SSH Daemon: Open the SSH configuration file:

    sudo nano /etc/ssh/sshd_config
    

    Find and modify these lines:

    • PermitRootLogin no (Disable root login)
    • PasswordAuthentication no (Disable password login, only allow SSH keys)
    • PubkeyAuthentication yes (Ensure public key authentication is enabled)
    • Port 2222 (Optional: Change SSH port from default 22 to a non-standard one for less automated scanning. Remember to use this port for all future SSH connections.)
    • 💡 Tip: If changing the port, remember to update your SSH client config or always specify -p 2222.

    Save and exit (Ctrl+X, Y, Enter).

  4. Restart SSH service:

    sudo systemctl restart sshd
    

    Important: Before closing your current root SSH session, open a new terminal window and try to log in as your new user:

    ssh -i ~/.ssh/id_ed25519 youruser@YOUR_VPS_IP_ADDRESS -p 2222 # If you changed the port
    

    If successful, you can safely close the root session. All subsequent server interactions should be as youruser.

Configuring UFW Firewall

UFW (Uncomplicated Firewall) is an easy-to-use frontend for iptables.

  1. Allow SSH (on your chosen port):
    sudo ufw allow 2222/tcp # Or 22/tcp if you kept the default port
    
  2. Allow HTTP and HTTPS:
    sudo ufw allow http
    sudo ufw allow https
    
    (These will allow ports 80 and 443 respectively).
  3. Enable UFW:
    sudo ufw enable
    
    Type y and Enter when prompted.
  4. Check UFW status:
    sudo ufw status verbose
    
    You should see rules for SSH, HTTP, and HTTPS.

Installing and Configuring Fail2Ban

Fail2Ban scans log files for malicious activity (like repeated failed login attempts) and bans the offending IP addresses using firewall rules.

  1. Install Fail2Ban:

    sudo apt update
    sudo apt install fail2ban
    
  2. Copy default config file:

    sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
    

    It’s best practice to edit jail.local as jail.conf might be overwritten on updates.

  3. Configure Fail2Ban: Open jail.local:

    sudo nano /etc/fail2ban/jail.local
    

    Find the [DEFAULT] section and adjust:

    • bantime = 10m (time in minutes an IP is banned, e.g., 10m, 1h)
    • findtime = 10m (time window for failures, e.g., 10m)
    • maxretry = 5 (number of failures before ban)
    • destemail = your_email@example.com (receive email alerts, optional)
    • sender = fail2ban@yourhostname (email sender, optional)
    • mta = sendmail (mail transfer agent, optional)

    Ensure [sshd] section has enabled = true. Save and exit.

  4. Restart Fail2Ban service:

    sudo systemctl restart fail2ban
    sudo systemctl enable fail2ban
    

    You can check its status with sudo systemctl status fail2ban.

5. Runtime Installation (Node.js)

We’ll use NVM (Node Version Manager) to install and manage Node.js versions, which is highly recommended for flexibility.

  1. Install NVM:
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
    
    • Close and reopen your SSH session to make NVM available, or run source ~/.bashrc (or ~/.zshrc if you use zsh).
  2. Install Node.js: You can install a specific LTS version (e.g., 20.x or 22.x).
    nvm install 20 # Installs the latest 20.x LTS version
    nvm use 20
    nvm alias default 20 # Set this version as default for new shells
    
  3. Verify installation:
    node -v
    npm -v
    
    You should see the installed versions.

6. Nginx Configuration

Nginx will serve as our reverse proxy, directing requests to the correct Node.js application port, handling static files, and managing SSL.

  1. Install Nginx:

    sudo apt update
    sudo apt install nginx
    sudo systemctl start nginx
    sudo systemctl enable nginx
    

    Check its status: sudo systemctl status nginx. You should be able to visit your VPS IP address in a browser and see the Nginx welcome page.

  2. Create Nginx Configuration File: We’ll create a new server block for your domain. Replace yourdomain.com with your actual domain.

    sudo nano /etc/nginx/sites-available/yourdomain.com
    

    Paste the following configuration:

    server {
        listen 80;
        server_name yourdomain.com www.yourdomain.com;
    
        # Redirect all HTTP traffic to HTTPS (will be uncommented after SSL setup)
        # return 301 https://$host$request_uri;
    }
    
    server {
        listen 443 ssl http2;
        server_name yourdomain.com www.yourdomain.com;
    
        # SSL Configuration (Certbot will fill this in later)
        ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
        include /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # Generated by Certbot
    
        # Gzip compression
        gzip on;
        gzip_proxied any;
        gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
        gzip_comp_level 5; # Adjust 1-9
        gzip_min_length 256;
        gzip_vary on;
        gzip_disable "msie6";
    
        # Basic Security Headers (add more as needed for production)
        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-XSS-Protection "1; mode=block";
        add_header X-Content-Type-Options "nosniff";
        add_header Referrer-Policy "no-referrer-when-downgrade";
        add_header Content-Security-Policy "default-src 'self' data: 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;"; # Adjust for your app's needs
    
        # Next.js Static Assets
        # This assumes Next.js build output is copied to /var/www/your-app/.next/static
        location ~ ^/_next/static/(.*)$ {
            alias /var/www/your-app/.next/static/$1;
            expires 30d; # Cache static assets
            access_log off;
            log_not_found off;
        }
    
        # Root and Catch-all Proxy for Next.js SSR/API routes
        location / {
            proxy_pass http://localhost:3000; # Assuming your Node.js app runs on port 3000
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
            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;
        }
    
        # Specific location for your custom Node.js/Express API (if not using Next.js API routes)
        # If your Express API is separate and on a different port, uncomment and adjust.
        # For a pure Next.js app with API routes, the above 'location /' handles everything.
        # location /api/ {
        #     proxy_pass http://localhost:3001; # Assuming Express API on port 3001
        #     proxy_http_version 1.1;
        #     proxy_set_header Upgrade $http_upgrade;
        #     proxy_set_header Connection 'upgrade';
        #     proxy_set_header Host $host;
        #     proxy_cache_bypass $http_upgrade;
        #     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;
        # }
    }
    

    Save and exit. ⚠ Important: The ssl_certificate and ssl_certificate_key paths will be generated by Certbot later. Leave them as they are for now. The return 301 https://$host$request_uri; line in the first server block should be commented out until SSL is set up and working.

  3. Enable the Nginx Configuration: Create a symbolic link from sites-available to sites-enabled:

    sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
    

    Remove the default Nginx configuration:

    sudo rm /etc/nginx/sites-enabled/default
    
  4. Test Nginx Configuration:

    sudo nginx -t
    

    You should see syntax is ok and test is successful. If not, check for typos.

  5. Restart Nginx:

    sudo systemctl restart nginx
    

7. SSL/TLS with Let’s Encrypt + Certbot

Let’s Encrypt provides free SSL certificates, and Certbot automates the process with Nginx.

  1. Install Certbot:

    sudo apt update
    sudo apt install certbot python3-certbot-nginx
    
  2. Obtain Certificates: Run Certbot for your domain. It will automatically detect your Nginx configuration.

    sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
    
    • Follow the prompts: Enter an email address for urgent renewals and security notices, agree to the terms of service.
    • Choose whether to redirect HTTP to HTTPS (select option 2: Redirect). Certbot will automatically uncomment the return 301 line in your Nginx config.
    • Certbot will then acquire and install your SSL certificates, and configure Nginx to use them.
  3. Verify SSL: Visit https://yourdomain.com in your browser. You should see a padlock icon indicating a secure connection.

  4. Automatic Renewal Configuration: Certbot automatically sets up a cron job or systemd timer for renewals. You can test the renewal process without actually renewing:

    sudo certbot renew --dry-run
    

    You should see output indicating a successful dry run. If there are any issues, address them promptly. ⚠ Important: Certificates are valid for 90 days. Automatic renewal is crucial.

8. Process Management (PM2 & systemd)

PM2 is a production process manager for Node.js applications with a built-in load balancer. systemd will ensure PM2 itself starts on boot.

  1. Install PM2 globally:

    npm install pm2@latest -g
    
  2. Start your Node.js application with PM2: First, ensure your application code is deployed (we’ll cover this properly in CI/CD, but for a manual test): Let’s assume your Next.js application (after next build) has its entry point for the custom server in server.js within the out or build directory, and your Express API (if separate) is api.js. Create a simple Next.js custom server (e.g., server.js in your Next.js project root):

    // server.js (Next.js custom server)
    const { createServer } = require('http');
    const { parse } = require('url');
    const next = require('next');
    
    const dev = process.env.NODE_ENV !== 'production';
    const hostname = 'localhost';
    const port = 3000; // Next.js server port
    
    const app = next({ dev, hostname, port });
    const handle = app.getRequestHandler();
    
    app.prepare().then(() => {
      createServer(async (req, res) => {
        try {
          // Be sure to pass `true` as the second argument to `url.parse`.
          // This tells it to parse the query portion of the URL.
          const parsedUrl = parse(req.url, true);
          const { pathname, query } = parsedUrl;
    
          // Example: A simple custom API route (if not using Next.js API routes)
          if (pathname === '/api/hello') {
            res.setHeader('Content-Type', 'application/json');
            res.end(JSON.stringify({ message: 'Hello from custom API!' }));
          } else {
            await handle(req, res, parsedUrl);
          }
        } catch (err) {
          console.error('Error occurred handling', req.url, err);
          res.statusCode = 500;
          res.end('internal server error');
        }
      })
      .once('error', (err) => {
        console.error(err);
        process.exit(1);
      })
      .listen(port, (err) => {
        if (err) throw err;
        console.log(`> Ready on http://${hostname}:${port}`);
      });
    });
    

    If you have a separate Express API on another port (e.g., 3001), you’d also create an api.js file for it.

    Now, from your application’s root directory on the server (/var/www/your-app):

    # For Next.js custom server:
    pm2 start server.js --name "next-app" --interpreter "node" --log-date-format "YYYY-MM-DD HH:mm:ss"
    
    # If you also had a separate Express API:
    # pm2 start api.js --name "express-api" --interpreter "node" --log-date-format "YYYY-MM-DD HH:mm:ss"
    
    • --name: Assigns a readable name.
    • --interpreter "node": Ensures PM2 uses the correct Node.js interpreter (especially useful with NVM).
    • --log-date-format: Helps with log readability.
  3. List PM2 processes:

    pm2 list
    
  4. Save PM2 process list and generate startup script: This ensures your applications start automatically after a server reboot.

    pm2 save
    sudo env PATH=$PATH:/home/youruser/.nvm/versions/node/v20.11.0/bin pm2 startup systemd -u youruser --hp /home/youruser
    # Adjust 'v20.11.0' to your actual Node.js version installed by NVM
    
    • The sudo env PATH=... part is crucial to ensure systemd finds the correct pm2 executable that NVM installed. The path will be similar to /home/youruser/.nvm/versions/node/vXX.YY.Z/bin.
    • This command will output a systemctl command you need to run to enable the generated service (e.g., sudo systemctl enable pm2-youruser). Copy and execute that command.
  5. Check PM2 service status:

    sudo systemctl status pm2-youruser
    

9. CI/CD with GitHub Actions (Node.js/Next.js Example)

Continuous Integration/Continuous Deployment automates the process of building and deploying your application.

Basic Workflow Structure

Create a .github/workflows/deploy.yml file in your repository’s root.

Building Your Application

For a Next.js application, this involves npm install and npm run build.

SSH Deployment (using scp and ssh)

We’ll use a GitHub Action that leverages SSH to copy files and execute commands on your server.

  1. Generate a new SSH key pair specifically for GitHub Actions: On your local machine, create a new key pair for this purpose. Do NOT reuse your personal SSH key.

    ssh-keygen -t ed25519 -C "github_actions_deploy_key" -f ~/.ssh/github_actions_deploy_key
    
    • Do not set a passphrase for this key, as it will be used by an automated system.
    • Copy the public key: cat ~/.ssh/github_actions_deploy_key.pub
  2. Add the public key to your server’s authorized_keys: SSH into your VPS as youruser and add the content of github_actions_deploy_key.pub to /home/youruser/.ssh/authorized_keys.

    # On your VPS, as 'youruser'
    nano ~/.ssh/authorized_keys
    # Paste the public key on a new line. Save and exit.
    

    Security: This key should only have permissions necessary for deployment. If you need more granular control, look into SSH user permissions.

  3. Add the private key to GitHub Secrets:

    • Go to your GitHub repository -> “Settings” -> “Secrets and variables” -> “Actions”.
    • Click “New repository secret”.
    • Name: SSH_PRIVATE_KEY
    • Secret: Paste the entire content of your private key (~/.ssh/github_actions_deploy_key). Make sure to include the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines.
    • Add another secret:
      • Name: SSH_HOST
      • Secret: Your VPS’s Public IPv4 Address
    • Add another secret:
      • Name: SSH_USERNAME
      • Secret: youruser (the non-root user you created)
    • Add another secret (if you changed the SSH port):
      • Name: SSH_PORT
      • Secret: 2222 (or your chosen port)
  4. Create the GitHub Actions Workflow file:

    # .github/workflows/deploy.yml
    name: Deploy Web App
    
    on:
      push:
        branches:
          - main # Or your deployment branch
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        environment: production # Optional: For environment-specific secrets/variables
    
        steps:
          - name: Checkout code
            uses: actions/checkout@v4
    
          - name: Set up Node.js
            uses: actions/setup-node@v4
            with:
              node-version: '20' # Match your server's Node.js version
    
          - name: Install dependencies
            run: npm ci
    
          - name: Build Next.js application
            run: npm run build
            env:
              # Pass production environment variables here that are needed during build
              # Example: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
              NEXT_PUBLIC_APP_ENV: production # Example
    
          - name: Archive build artifacts
            run: tar -czf release.tar.gz .next public server.js package.json package-lock.json node_modules
    
          - name: Deploy to VPS
            uses: appleboy/ssh-action@master
            with:
              host: ${{ secrets.SSH_HOST }}
              username: ${{ secrets.SSH_USERNAME }}
              key: ${{ secrets.SSH_PRIVATE_KEY }}
              port: ${{ secrets.SSH_PORT }} # Optional, only if you changed SSH port
              script: |
                # Create deployment directory if it doesn't exist
                mkdir -p /var/www/your-app-temp
                # Remove previous temp directory content
                rm -rf /var/www/your-app-temp/*
    
                # Transfer build artifact (release.tar.gz)
                # We'll use scp for the actual transfer in a separate step for better robustness.
                # This 'script' block will only handle server-side commands after transfer.
    
          - name: Transfer build artifacts via SCP
            uses: appleboy/scp-action@master
            with:
              host: ${{ secrets.SSH_HOST }}
              username: ${{ secrets.SSH_USERNAME }}
              key: ${{ secrets.SSH_PRIVATE_KEY }}
              port: ${{ secrets.SSH_PORT }} # Optional
              source: "release.tar.gz"
              target: "/var/www/your-app-temp/" # Transfer to a temporary directory
    
          - name: Unpack and Activate New Release
            uses: appleboy/ssh-action@master
            with:
              host: ${{ secrets.SSH_HOST }}
              username: ${{ secrets.SSH_USERNAME }}
              key: ${{ secrets.SSH_PRIVATE_KEY }}
              port: ${{ secrets.SSH_PORT }} # Optional
              script: |
                # Ensure the target directory exists
                mkdir -p /var/www/your-app
    
                # Unpack the new release into a new versioned directory
                # This is a simplified blue-green for a single server
                TIMESTAMP=$(date +%Y%m%d%H%M%S)
                RELEASE_DIR="/var/www/your-app-releases/release-$TIMESTAMP"
                mkdir -p $RELEASE_DIR
                tar -xzf /var/www/your-app-temp/release.tar.gz -C $RELEASE_DIR
    
                # Update symlink to point to the new release
                # This provides a near-instant switch if Nginx is configured correctly
                ln -sfn $RELEASE_DIR /var/www/your-app
    
                # Clean up old temp archive
                rm -rf /var/www/your-app-temp/release.tar.gz
    
                # Restart PM2 process to pick up new code
                # This uses the 'gracefulReload' for zero-downtime, but it's not truly zero-downtime
                # if there's only one instance. A load balancer is needed for true zero-downtime.
                pm2 reload next-app --update-env # 'next-app' is the PM2 process name
    
                # Optional: Clean up old releases (keep last 5, for example)
                # ls -td /var/www/your-app-releases/* | tail -n +6 | xargs rm -rf
    

Environment Variables & Secrets in CI/CD

  • Any sensitive information (database credentials, API keys) needed by your application at runtime should be passed as environment variables to your PM2 process.
  • Secrets in GitHub Actions are accessible via secrets.SECRET_NAME.
  • For Next.js, NEXT_PUBLIC_ prefixed variables are exposed to the browser. Others are server-side only.

10. Zero-Downtime Deployment Strategies

True zero-downtime for a single server is challenging. For robust zero-downtime, you need a load balancer with multiple application instances. However, we can minimize downtime on a single server.

  • Blue/Green (Simplified for Single Server): The GitHub Actions workflow above implements a simplified blue-green.

    1. Deploy the new version of your app to a new directory (the “green” environment).
    2. Once confirmed, update a symbolic link (/var/www/your-app -> release-xxxx) to point to the new version.
    3. Restart your process manager (pm2 reload). The pm2 reload command is designed to restart processes gracefully (starting new instances before stopping old ones), minimizing downtime. Nginx points to the symlink, so it picks up the change.
  • Rolling Deployments (Requires Load Balancer):

    1. You have multiple identical VPS instances behind a load balancer.
    2. You deploy the new version to one instance, take it out of the load balancer’s rotation.
    3. Once updated and tested, put it back in rotation.
    4. Repeat for the next instance until all are updated. This ensures traffic is always served by healthy instances.

11. Database Setup (Managed & Self-Hosted PostgreSQL)

Managed PostgreSQL (Linode/DigitalOcean)

This is the recommended approach for ease of management, backups, and scalability, especially for beginners.

  1. Provision a Managed Database:

    • Linode: In Cloud Manager -> “Databases” -> “Create Database Cluster”. Choose “PostgreSQL”, desired version, region, and plan.
    • DigitalOcean: In Control Panel -> “Databases” -> “Create a database cluster”. Choose “PostgreSQL”, desired version, region, and plan.
  2. Note Connection Details:

    • After creation, you’ll get a hostname, port, database name, username, and password. These are your application’s DATABASE_URL components.
    • Crucial: Configure “trusted sources” or “firewall rules” on the managed database to only allow connections from your VPS’s IP address (and potentially your local development IP if needed for direct access). Never expose it to the public internet.
  3. Example Environment Variable (for your Node.js app):

    DATABASE_URL="postgresql://user:password@host:port/database"
    

Self-Hosted PostgreSQL (Ubuntu)

This gives you full control but requires more management overhead (backups, updates, monitoring). Only do this on the same VPS as your application, or securely on a private network, never directly exposed to the internet.

  1. Install PostgreSQL:

    sudo apt update
    sudo apt install postgresql postgresql-contrib
    
  2. Access PostgreSQL prompt as the postgres user:

    sudo -i -u postgres psql
    
  3. Create a new database and user for your application:

    CREATE DATABASE your_app_db;
    CREATE USER your_app_user WITH ENCRYPTED PASSWORD 'your_secure_password';
    GRANT ALL PRIVILEGES ON DATABASE your_app_db TO your_app_user;
    \q # Exit psql
    

    Important: Choose a very strong password.

  4. Configure PostgreSQL to allow local connections: By default, PostgreSQL is secure. It should only listen on localhost (127.0.0.1) unless explicitly configured otherwise for internal networks. Edit pg_hba.conf to ensure your application can connect.

    sudo nano /etc/postgresql/14/main/pg_hba.conf # Version might differ (e.g., 15)
    

    Ensure you have a line like this for your user (the scram-sha-256 method is recommended):

    # TYPE  DATABASE        USER            ADDRESS                 METHOD
    host    your_app_db     your_app_user   127.0.0.1/32            scram-sha-256
    

    Save and exit.

  5. Restart PostgreSQL:

    sudo systemctl restart postgresql
    
  6. Example Environment Variable:

    DATABASE_URL="postgresql://your_app_user:your_secure_password@localhost:5432/your_app_db"
    

12. CDN Integration with Cloudflare

Cloudflare provides a free CDN, DDoS protection, and a robust DNS manager.

  1. Sign up for Cloudflare: Go to cloudflare.com and create an account.
  2. Add your site: Follow the prompts to add your domain.
  3. Scan for existing DNS records: Cloudflare will attempt to import your current DNS records. Review them carefully to ensure they match what you set up at your registrar.
    • Ensure your A record for @ and www (or other subdomains pointing to your VPS) are present and proxied (orange cloud icon). This means traffic goes through Cloudflare.
  4. Change Nameservers: Cloudflare will give you two nameservers (e.g., eva.ns.cloudflare.com, mike.ns.cloudflare.com). You need to update your domain registrar’s settings to use these nameservers.
    • Log in to your domain registrar (e.g., Namecheap, Google Domains).
    • Find the “Nameservers” or “DNS Management” section.
    • Change from your registrar’s default nameservers to Cloudflare’s.
    • Important: After changing nameservers, DNS propagation will occur again (can take up to 48 hours, but usually faster). Your site might be temporarily unreachable during this switch.
  5. Configure Cloudflare Settings:
    • SSL/TLS -> Overview:
      • SSL/TLS encryption mode: Set to “Full (strict)”. This encrypts traffic between the user and Cloudflare, and between Cloudflare and your origin server (your Nginx/Certbot setup).
      • 💡 Tip: Avoid “Flexible” as it only encrypts part of the path, leaving traffic between Cloudflare and your server unencrypted.
    • Speed -> Optimization:
      • Auto Minify: Enable for JavaScript, CSS, HTML.
      • Brotli: Enable for better compression.
    • Caching -> Configuration:
      • Caching Level: “Standard” is usually fine. “Aggressive” might cache more, but ensure your app handles it.
      • Browser Cache TTL: Set to a reasonable time (e.g., 4 hours).
    • Security -> DDoS: Cloudflare automatically provides DDoS protection on the free tier.
    • Firewall -> WAF: The free tier offers basic managed rules.
    • Rules -> Page Rules: You can set up custom caching rules, redirects, or security features. For example, to aggressively cache static assets not handled by _next/static or to bypass caching for admin routes.

13. Environment Variables & Secrets Management

Properly managing environment variables and secrets is critical for security and maintainability.

  • Local Development (.env): Use a .env file in your project root (e.g., with dotenv library) for local development.

    # .env (example for local development)
    NODE_ENV=development
    PORT=3000
    DATABASE_URL="postgresql://localuser:localpass@localhost:5432/local_db"
    NEXT_PUBLIC_API_URL=http://localhost:3000/api
    

    Security: Add .env to your .gitignore! Never commit it to version control.

  • Server-Side Environment Variables:

    • PM2: You can pass environment variables directly when starting PM2 processes, or use a PM2 ecosystem file.
      // ecosystem.config.js
      module.exports = {
        apps : [{
          name: "next-app",
          script: "./server.js",
          interpreter: "node",
          watch: false,
          env: {
              NODE_ENV: "production",
              PORT: 3000,
              DATABASE_URL: "postgresql://your_app_user:your_secure_password@localhost:5432/your_app_db"
          }
        }]
      };
      
      Then, pm2 start ecosystem.config.js.
    • systemd: If you’re using a plain systemd service, define variables in the [Service] section:
      # ...
      Environment="NODE_ENV=production"
      Environment="PORT=3000"
      Environment="DATABASE_URL=postgresql://your_app_user:your_secure_password@localhost:5432/your_app_db"
      # ...
      
      Security: For highly sensitive secrets, consider using a dedicated secret management service or, on a single server, carefully restricted files or a simple script that sets env vars. However, for a simple setup, systemd or PM2’s env block is often sufficient if the file itself is secure.
  • Secrets in CI/CD (GitHub Secrets): As shown in the GitHub Actions example, securely store sensitive values like SSH_PRIVATE_KEY, SSH_HOST, DATABASE_URL (if needed for build steps or direct deployment logic) in GitHub Secrets. These are encrypted and not exposed in logs.

14. Monitoring & Logging

Understanding your application’s health and behavior is crucial.

  • Accessing Server Logs:

    • Nginx Logs:
      • Access logs (who is accessing your site): tail -f /var/log/nginx/access.log
      • Error logs (Nginx errors): tail -f /var/log/nginx/error.log
    • Application Logs (PM2):
      • View PM2 logs: pm2 logs next-app (replace next-app with your app’s name)
      • View all PM2 logs: pm2 logs
      • PM2 also writes logs to files, usually in ~/.pm2/logs/.
    • systemd Journal:
      • If using systemd directly (or for PM2’s service): journalctl -u your-app.service -f (-f for follow, -u for unit).
  • Basic Metrics (CPU, Memory, Disk):

    • htop: Interactive process viewer (install with sudo apt install htop).
    • free -h: Check memory usage.
    • df -h: Check disk space usage.
    • Linode/DigitalOcean dashboards provide basic CPU/Memory/Network graphs.
  • Uptime Checks & Basic Alerting:

    • UptimeRobot (Free Tier): Monitors your website’s availability (HTTP/S, Ping). Sends email/SMS/Webhook alerts if your site goes down.
    • Healthchecks.io (Free Tier): Simple service to monitor cron jobs or periodic tasks. Your app can “ping” it, and if it doesn’t, Healthchecks.io alerts you.
    • Cron Jobs: You can set up a simple cron job on your server to check the status of your app or send reports.
      # Example cron job to restart app if it crashes (PM2 does this, but for general processes)
      # Add to crontab -e
      # */5 * * * * /usr/bin/pgrep -f "node server.js" || pm2 restart next-app
      

15. Scaling with Load Balancing (Linode/DigitalOcean Examples)

When a single server can no longer handle traffic, or you need higher availability, a load balancer is the next step.

Architecture Overview (Load Balanced)

(Refer to the ASCII diagram in Section 1)

Setting up a Load Balancer

Linode NodeBalancer:

  1. In Linode Cloud Manager -> “NodeBalancers” -> “Create NodeBalancer”.
  2. Choose a region.
  3. Configure a listening port (e.g., 80 and 443).
  4. For port 443 (HTTPS), select “HTTPS” protocol and choose “Terminate SSL” on the NodeBalancer. You will need to upload your SSL certificate and private key obtained from Let’s Encrypt (usually /etc/letsencrypt/live/yourdomain.com/fullchain.pem and /etc/letsencrypt/live/yourdomain.com/privkey.pem). This Offloads SSL encryption from your backend servers.
    • Alternatively, you can choose “TCP” and handle SSL on each Nginx instance, but terminating at the LB is common.
  5. Add Backend Nodes (your VPS instances).
    • Specify their private IP addresses (if using a VPC) or public IP addresses.
    • Specify the port your Nginx is listening on (e.g., 80 or 443 if Nginx handles SSL).
  6. Configure Health Checks for each backend. This ensures traffic is only sent to healthy servers.
    • Type: HTTP
    • Port: 80 (or 443)
    • Path: / or a specific health check endpoint on your app (e.g., /health).
    • Interval/Timeout/Attempts: Adjust as needed.

DigitalOcean Load Balancer:

  1. In DigitalOcean Control Panel -> “Networking” -> “Load Balancers” -> “Create Load Balancer”.
  2. Choose a region.
  3. Choose your Droplets (VPS instances) to add as backend nodes.
  4. Configure Forwarding Rules:
    • Frontend Protocol/Port (e.g., HTTP on 80, HTTPS on 443)
    • Backend Protocol/Port (e.g., HTTP on 80, HTTPS on 443).
    • For HTTPS, you can choose to “Encrypt traffic to Droplets” (meaning the LB will connect to your Nginx with HTTPS) or “HTTP” if the LB terminates SSL and sends HTTP internally.
  5. SSL Configuration: DigitalOcean allows you to upload certificates or use a free Let’s Encrypt certificate directly on the Load Balancer. This is often the simplest approach for managing SSL at scale.
  6. Health Checks: Configure path, port, interval.

Adding Backend Servers

Create identical copies of your initial VPS setup. Ensure they are configured identically (same Node.js version, same Nginx config, same PM2 setup). For optimal performance and security, place them in a private network (VPC/VLAN) if your provider offers it, and have the load balancer communicate via private IPs.

Health Checks

A dedicated health check endpoint in your Node.js/Express app is a good practice.

// In your server.js or a separate API file
app.get('/health', (req, res) => {
  // You might check database connection, or other vital services
  res.status(200).send('OK');
});

Configure your load balancer to probe this endpoint.

Session Persistence (if needed)

If your application uses server-side sessions, ensure you use a distributed session store (e.g., Redis, PostgreSQL) rather than in-memory sessions. Otherwise, users might be logged out when switching servers. Load balancers can also offer “sticky sessions” (directing a user to the same backend server), but this limits true load distribution and resilience.

16. Security Checklist

  • Regular Updates: Regularly update your server’s packages: sudo apt update && sudo apt upgrade.
  • Strong Passwords/SSH Keys: Always use strong, unique passphrases for SSH keys and passwords for any services.
  • Least Privilege Principle: Run your application with a dedicated non-root user (youruser in our example).
  • Input Validation: Sanitize and validate all user input on both client and server sides to prevent injections (SQL, XSS, etc.).
  • Security Headers: Nginx configuration includes basic security headers. Research and add more based on your application’s needs (e.g., strict-transport-security, CSP).
  • Environment Variables for Secrets: Never hardcode sensitive credentials in your code.
  • Firewall Rules: Only open necessary ports (SSH, HTTP, HTTPS).
  • Fail2Ban: Keep it active to deter brute-force attacks.
  • Backups: Regularly back up your database and application code.
  • Auditing: Periodically review server logs and application logs for suspicious activity.
  • Dependencies: Keep your Node.js packages updated and scan for vulnerabilities (e.g., npm audit).

17. Cost Optimization Tips

  • Right-sizing VPS: Start with the smallest viable VPS and scale up only when monitoring indicates resource limitations.
  • Leveraging CDN: Cloudflare’s free tier offloads a significant amount of traffic and protects your origin server, reducing bandwidth costs.
  • Managed vs. Self-Hosted: Managed databases cost more but save significant time and effort in management. For truly low budget, self-hosting is cheaper but demands more expertise.
  • Monitoring Resource Usage: Use tools like htop, free -h, and your VPS provider’s dashboards to track resource usage and identify bottlenecks.
  • Free Tier Services: Utilize free tiers of services like UptimeRobot, GitHub Actions.
  • Scheduled Shutdowns (Dev/Staging): For non-production environments, consider scripting VPS shutdowns during off-hours to save costs (if your provider supports it for billing).

18. Common Pitfalls & Troubleshooting

  • DNS Issues:
    • Symptom: Your domain doesn’t resolve to your VPS IP, or your Cloudflare nameservers aren’t working.
    • Fix: Use dig or whatsmydns.net to verify DNS propagation. Double-check A/CNAME records and Cloudflare nameservers at your registrar.
  • Firewall Blocks:
    • Symptom: Can’t SSH, or website isn’t reachable (even if Nginx is running).
    • Fix: sudo ufw status verbose. Ensure ports 22/2222 (SSH), 80 (HTTP), 443 (HTTPS) are allowed. Temporarily disable sudo ufw disable for testing, but re-enable quickly.
  • Nginx Configuration Errors:
    • Symptom: Nginx fails to start, or shows a 502 Bad Gateway error.
    • Fix: sudo nginx -t to check syntax. Review Nginx logs (/var/log/nginx/error.log). A 502 often means Nginx can’t connect to your Node.js app (check proxy_pass URL and app port).
  • Application Crashes:
    • Symptom: Your website returns 502/503 errors, or specific routes fail.
    • Fix: Check application logs (pm2 logs next-app or journalctl -u your-app.service). Use pm2 status to see if your app is running or restarting constantly.
  • Permission Problems:
    • Symptom: Application can’t write to files, or Nginx can’t read static assets.
    • Fix: Check file/directory permissions (ls -l). Ensure your youruser has appropriate read/write access to your application directory (/var/www/your-app). Nginx typically runs as www-data, so ensure static files are readable by this user.
  • SSL Errors:
    • Symptom: Browser shows “Not Secure” or certificate errors.
    • Fix: Ensure Certbot ran successfully. Check Nginx config for correct SSL paths. Verify Cloudflare SSL/TLS mode is “Full (strict)”.
  • CI/CD Failures:
    • Symptom: GitHub Actions workflow fails during build or deploy.
    • Fix: Review the GitHub Actions job logs carefully. Look for specific error messages. Check environment variables and secrets. Ensure your deploy_key is correctly added to GitHub Secrets and your server’s authorized_keys.

19. Reusable Checklists

Pre-Deployment Checklist

  • Domain registered and DNS records pointing to VPS IP.
  • SSH key pair generated (local machine).
  • VPS provisioned (Ubuntu LTS) with SSH key installed.
  • Non-root user created with sudo access.
  • SSH hardened (password auth disabled, root login disabled, optional port change).
  • UFW configured and enabled (allow SSH, HTTP, HTTPS).
  • Fail2Ban installed and configured.
  • Node.js installed on server via NVM.
  • PostgreSQL database provisioned (managed or self-hosted).
  • Database credentials secured and accessible to app.
  • Next.js app configured to use production environment variables.
  • GitHub repository created.
  • GitHub Actions deploy SSH key generated and added to server & GitHub secrets.
  • All sensitive variables added to GitHub Secrets.

Post-Deployment Checklist

  • Website accessible via https://yourdomain.com.
  • SSL certificate is valid and auto-renewing (sudo certbot renew --dry-run).
  • All API endpoints working correctly.
  • Static assets loading from CDN (Cloudflare).
  • Application running under PM2 and auto-starts on reboot (sudo systemctl status pm2-youruser).
  • All environment variables correctly loaded in production.
  • GitHub Actions workflow completes successfully on push to main.
  • Monitoring (uptime checks, logs) configured and sending alerts.
  • Database backups configured (if self-hosted).
  • Old releases cleaned up (if using release strategy).
  • Manual tests performed for critical functionalities.

20. Full Working Example: Next.js + Node.js/Express + PostgreSQL

This section provides concrete examples for the chosen tech stack.

Example App Structure

Let’s assume a monorepo-like structure or just a simple Next.js project with a custom server for simplicity.

/your-app/
├── .next/                    # Next.js build output (generated by `npm run build`)
├── node_modules/
├── public/                   # Static assets for Next.js (e.g., favicon.ico, images)
├── pages/                    # Next.js pages (e.g., index.js, api/hello.js)
├── components/
├── styles/
├── server.js                 # Custom Node.js/Express server (handles Next.js & potential custom API)
├── package.json
├── package-lock.json
├── .gitignore
└── .github/
    └── workflows/
        └── deploy.yml

server.js (Custom Next.js Server & simple API):

This server.js will handle all requests. It uses Next.js’s getRequestHandler for pages and API routes, and includes a simple custom /api/hello Express route for demonstration, which could connect to your PostgreSQL database.

// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const express = require('express');
const { Client } = require('pg'); // For PostgreSQL connection

const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = parseInt(process.env.PORT, 10) || 3000;

const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();

// PostgreSQL Client (example)
const pgClient = new Client({
  connectionString: process.env.DATABASE_URL,
});

app.prepare().then(() => {
  const server = express();

  // Middleware for parsing JSON bodies
  server.use(express.json());

  // Connect to PostgreSQL (optional, you might connect on demand in real app)
  pgClient.connect((err) => {
    if (err) {
      console.error('Error connecting to PostgreSQL:', err.stack);
    } else {
      console.log('Connected to PostgreSQL database!');
    }
  });

  // Custom API Route Example
  server.get('/api/hello', async (req, res) => {
    try {
      // Example: Query database
      const result = await pgClient.query('SELECT NOW() as current_time');
      res.json({
        message: 'Hello from Node.js Express API!',
        databaseTime: result.rows[0].current_time,
      });
    } catch (err) {
      console.error('Error fetching from DB:', err.message);
      res.status(500).json({ error: 'Database error' });
    }
  });

  // Next.js request handler for all other routes (pages, Next.js API routes, static)
  server.all('*', (req, res) => {
    return handle(req, res);
  });

  server.listen(port, (err) => {
    if (err) throw err;
    console.log(`> Ready on http://${hostname}:${port}`);
    console.log(`> Environment: ${process.env.NODE_ENV}`);
    console.log(`> Database URL: ${process.env.DATABASE_URL ? 'Configured' : 'Not Configured'}`);
  });
});

// Graceful shutdown
process.on('SIGTERM', () => {
  console.log('SIGTERM received, closing PostgreSQL connection...');
  pgClient.end(() => {
    console.log('PostgreSQL connection closed. Exiting process.');
    process.exit(0);
  });
});

package.json (relevant scripts):

{
  "name": "your-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "next": "^14.2.5",
    "pg": "^8.12.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  }
}

Nginx Config File (/etc/nginx/sites-available/yourdomain.com)

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    # Redirect all HTTP traffic to HTTPS (Certbot will uncomment this)
    # return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    # Certbot will populate these paths after running
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Gzip compression
    gzip on;
    gzip_proxied any;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml+rss text/javascript;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_vary on;
    gzip_disable "msie6";

    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options "nosniff";
    add_header Referrer-Policy "no-referrer-when-downgrade";
    # Adjust Content-Security-Policy based on your actual app's needs
    add_header Content-Security-Policy "default-src 'self' data: 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;";

    # Handle Next.js static assets served by Nginx (for better performance/caching)
    # This caches resources from .next/static and public folders
    location ~ ^/(?:_next/static|public)/(.*)$ {
        alias /var/www/your-app/$1; # Assuming /var/www/your-app is the symlink to current release
        expires 30d;
        access_log off;
        log_not_found off;
    }

    # Proxy all other requests to the Node.js/Next.js application
    location / {
        proxy_pass http://localhost:3000; # Node.js app runs on port 3000
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        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;
    }

    # You can add specific locations if you have non-Next.js API routes that need different proxying
    # location /api/custom/ {
    #     proxy_pass http://localhost:3001; # Example for a separate custom API
    #     # ... proxy headers
    # }
}

PM2 Configuration (ecosystem.config.js on server)

This file resides in the root of your application (/var/www/your-app/ecosystem.config.js).

// ecosystem.config.js
module.exports = {
  apps : [{
    name: "next-app",
    script: "./server.js", // Your Next.js custom server entry point
    interpreter: "node",
    args: "start", // Pass 'start' arg if your script needs it for production
    watch: false, // Set to true for development, false for production
    instances: "max", // Run as many instances as CPU cores for load balancing
    exec_mode: "cluster", // PM2's built-in cluster mode
    log_date_format: "YYYY-MM-DD HH:mm:ss",
    error_file: "/var/www/your-app/logs/next-app-error.log",
    out_file: "/var/www/your-app/logs/next-app-out.log",
    // Environment variables for production
    env: {
      NODE_ENV: "production",
      PORT: 3000, // Port your Next.js app listens on internally
      DATABASE_URL: "postgresql://your_app_user:your_secure_password@localhost:5432/your_app_db",
      NEXT_PUBLIC_API_URL: "https://yourdomain.com/api" // Public URL
    }
  }]
};

systemd Service File (generated by PM2, example pm2-youruser.service)

This file is generated by pm2 startup systemd and usually resides in /etc/systemd/system/.

# /etc/systemd/system/pm2-youruser.service
[Unit]
Description=PM2 process manager for user youruser
After=network.target

[Service]
Type=forking
User=youruser
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
WorkingDirectory=/home/youruser
# Note: The ExecStart path will vary based on your NVM installation and Node.js version.
# This path is dynamically generated by 'pm2 startup systemd'.
ExecStart=/home/youruser/.nvm/versions/node/v20.11.0/bin/pm2 resurrect
ExecReload=/home/youruser/.nvm/versions/node/v20.11.0/bin/pm2 reload all
ExecStop=/home/youruser/.nvm/versions/node/v20.11.0/bin/pm2 kill
Environment=PATH=/home/youruser/.nvm/versions/node/v20.11.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
# Important: Add any other environment variables PM2 might need here if not in ecosystem.config.js
# Or ensure they are available in the shell PM2 is started from.
# Environment="DATABASE_URL=..." # Can be added here, but prefer ecosystem.config.js for app-specific ones

[Install]
WantedBy=multi-user.target

GitHub Actions Workflow (.github/workflows/deploy.yml)

# .github/workflows/deploy.yml
name: Deploy Next.js App to VPS

on:
  push:
    branches:
      - main # Trigger on pushes to the 'main' branch

jobs:
  deploy:
    runs-on: ubuntu-latest # Use a fresh Ubuntu runner for each deployment
    environment: production # Use a GitHub Environment for production secrets

    steps:
      - name: Checkout code
        uses: actions/checkout@v4 # Checkout your repository's code

      - name: Set up Node.js environment
        uses: actions/setup-node@v4
        with:
          node-version: '20' # Specify the Node.js version (match your server's NVM version)
          cache: 'npm' # Cache npm dependencies for faster builds

      - name: Install dependencies
        run: npm ci # Use npm ci for clean installs in CI

      - name: Build Next.js application
        run: npm run build
        env:
          # Pass public environment variables needed during build
          NEXT_PUBLIC_APP_ENV: production
          NEXT_PUBLIC_API_URL: https://${{ secrets.DOMAIN_NAME }}/api # Using a secret for the domain

      - name: Prepare deployment archive
        # Archive only the necessary files for deployment to save transfer time
        run: tar -czf release.tar.gz .next public server.js package.json ecosystem.config.js

      - name: Transfer build artifacts via SCP
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }} # Use the secret for SSH port
          source: "release.tar.gz" # Source archive from current workspace
          target: "/tmp/" # Temporary location on the server

      - name: Deploy and Restart Application
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }} # Use the secret for SSH port
          script: |
            # Define paths
            APP_ROOT="/var/www/your-app"
            RELEASES_DIR="/var/www/your-app-releases"
            RELEASE_TIMESTAMP=$(date +%Y%m%d%H%M%S)
            CURRENT_RELEASE_DIR="$RELEASES_DIR/release-$RELEASE_TIMESTAMP"
            SYMLINK_PATH="/var/www/your-app" # This is what Nginx points to

            echo "--- Starting Deployment ---"

            # 1. Create releases directory if it doesn't exist
            mkdir -p $RELEASES_DIR
            echo "Created releases directory: $RELEASES_DIR"

            # 2. Create the new specific release directory
            mkdir -p $CURRENT_RELEASE_DIR
            echo "Created current release directory: $CURRENT_RELEASE_DIR"

            # 3. Unpack the new release archive into the new release directory
            echo "Unpacking release.tar.gz to $CURRENT_RELEASE_DIR..."
            tar -xzf /tmp/release.tar.gz -C $CURRENT_RELEASE_DIR
            echo "Unpacking complete."

            # 4. Remove the temporary archive from the server
            rm /tmp/release.tar.gz
            echo "Removed temporary archive /tmp/release.tar.gz"

            # 5. Update the symbolic link to point to the new release
            # This is the "atomic switch" for near zero-downtime
            ln -sfn $CURRENT_RELEASE_DIR $SYMLINK_PATH
            echo "Symlink updated to point to: $SYMLINK_PATH -> $CURRENT_RELEASE_DIR"

            # 6. Install Node modules in the new release directory
            # Important: npm ci on the server ensures dependencies are correct
            echo "Installing node modules in new release directory..."
            cd $CURRENT_RELEASE_DIR
            npm ci --production --ignore-scripts # --production avoids dev dependencies, --ignore-scripts for security
            echo "Node modules installed."

            # 7. Reload PM2 process to load the new code
            echo "Reloading PM2 process 'next-app'..."
            pm2 reload next-app --update-env # --update-env ensures new env vars are loaded
            echo "PM2 reload initiated."

            # 8. Clean up old releases (optional: keep last 5)
            echo "Cleaning up old releases..."
            # Adjust 'tail -n +6' to keep more or fewer releases
            ls -td $RELEASES_DIR/* | tail -n +6 | xargs rm -rf
            echo "Old releases cleaned up."

            echo "--- Deployment Finished Successfully! ---"            

New GitHub Secret needed for the workflow: DOMAIN_NAME (e.g., yourdomain.com).

End-to-End Deployment Walkthrough (Manual Steps + CI/CD)

Part 1: Initial Server Setup (Manual)

  1. Purchase Domain & Configure DNS: Buy yourdomain.com. In your registrar, set up A records pointing yourdomain.com and www.yourdomain.com to your VPS IP. Wait for propagation.
  2. Provision VPS: Create an Ubuntu 22.04/24.04 LTS VPS on Linode or DigitalOcean. Add your personal SSH public key during creation.
  3. Initial SSH & User Setup:
    ssh root@YOUR_VPS_IP
    adduser youruser
    usermod -aG sudo youruser
    mkdir -p /home/youruser/.ssh
    cp ~/.ssh/authorized_keys /home/youruser/.ssh/
    chown -R youruser:youruser /home/youruser/.ssh
    chmod 700 /home/youruser/.ssh
    chmod 600 /home/youruser/.ssh/authorized_keys
    nano /etc/ssh/sshd_config # Set PermitRootLogin no, PasswordAuthentication no, Port 2222
    systemctl restart sshd
    # Test new SSH connection in a new terminal:
    ssh -p 2222 youruser@YOUR_VPS_IP
    # Close root session if successful
    
  4. Firewall & Fail2Ban:
    sudo ufw allow 2222/tcp
    sudo ufw allow http
    sudo ufw allow https
    sudo ufw enable
    sudo apt install fail2ban
    sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
    sudo nano /etc/fail2ban/jail.local # Review/adjust bantime, maxretry
    sudo systemctl restart fail2ban
    
  5. Install Node.js (via NVM):
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
    # Close and reopen SSH session OR `source ~/.bashrc`
    nvm install 20
    nvm use 20
    nvm alias default 20
    
  6. Install Nginx:
    sudo apt install nginx
    sudo systemctl start nginx
    sudo systemctl enable nginx
    
  7. Create Nginx Config:
    sudo nano /etc/nginx/sites-available/yourdomain.com
    # Paste the Nginx config provided above (replace yourdomain.com, keep SSL lines as is for Certbot)
    sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
    sudo rm /etc/nginx/sites-enabled/default
    sudo nginx -t
    sudo systemctl restart nginx
    
  8. Set up Let’s Encrypt SSL:
    sudo apt install certbot python3-certbot-nginx
    sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com # Follow prompts, choose Redirect
    sudo certbot renew --dry-run
    
  9. Install PM2 and configure startup:
    npm install pm2@latest -g
    # Note down your Node.js path from `which node` or `nvm which current`
    # Example: /home/youruser/.nvm/versions/node/v20.11.0/bin/pm2
    sudo env PATH=$PATH:/home/youruser/.nvm/versions/node/v20.11.0/bin pm2 startup systemd -u youruser --hp /home/youruser
    # Copy and execute the systemctl command it outputs, e.g.:
    # sudo systemctl enable pm2-youruser.service
    
  10. Provision Managed PostgreSQL (or install self-hosted):
    • Managed: Create a PostgreSQL instance on Linode/DigitalOcean. Note down the DATABASE_URL or its components.
    • Self-Hosted:
      sudo apt install postgresql postgresql-contrib
      sudo -i -u postgres psql
      CREATE DATABASE your_app_db;
      CREATE USER your_app_user WITH ENCRYPTED PASSWORD 'your_secure_password';
      GRANT ALL PRIVILEGES ON DATABASE your_app_db TO your_app_user;
      \q
      sudo nano /etc/postgresql/14/main/pg_hba.conf # Add host entry
      sudo systemctl restart postgresql
      
  11. Configure Cloudflare: Add your site to Cloudflare. Change nameservers at your domain registrar. Set SSL/TLS to “Full (strict)”.

Part 2: Local Project Setup & GitHub Integration

  1. Create Your Next.js App:
    npx create-next-app@latest your-app --use-npm --ts # Or JavaScript
    cd your-app
    # Create server.js, install express, pg:
    npm install express pg
    
  2. Add server.js: Copy the server.js content provided above into your project root.
  3. Create ecosystem.config.js: Copy the PM2 config into your project root.
    • Fill in DATABASE_URL with your production database connection string.
    • Fill in NEXT_PUBLIC_API_URL with your production domain (https://yourdomain.com/api).
  4. Initialize Git & Create GitHub Repository:
    git init
    git add .
    git commit -m "Initial commit: Next.js app with custom server"
    git branch -M main
    git remote add origin https://github.com/yourusername/your-app.git
    git push -u origin main
    
  5. Generate GitHub Actions Deploy Key:
    ssh-keygen -t ed25519 -C "github_actions_deploy_key" -f ~/.ssh/github_actions_deploy_key
    # DO NOT SET A PASSPHRASE
    cat ~/.ssh/github_actions_deploy_key.pub # Copy this public key
    
  6. Add Deploy Key to Server: SSH into your VPS (ssh -p 2222 youruser@YOUR_VPS_IP) and add the public key to /home/youruser/.ssh/authorized_keys.
  7. Add Secrets to GitHub: Go to your GitHub repo -> “Settings” -> “Secrets and variables” -> “Actions”.
    • SSH_PRIVATE_KEY: Content of ~/.ssh/github_actions_deploy_key (the private key).
    • SSH_HOST: Your VPS Public IPv4 Address.
    • SSH_USERNAME: youruser.
    • SSH_PORT: 2222 (or your chosen port).
    • DOMAIN_NAME: yourdomain.com.
  8. Create GitHub Actions Workflow: Create the directory .github/workflows/ in your project root. Create deploy.yml inside it and paste the GitHub Actions workflow content provided above.

Part 3: First Deployment (CI/CD)

  1. Push to main:
    git add .github/workflows/deploy.yml ecosystem.config.js # And any other new files
    git commit -m "Add GitHub Actions deployment workflow and PM2 config"
    git push origin main
    
  2. Monitor GitHub Actions: Go to your GitHub repo -> “Actions”. You should see your “Deploy Web App” workflow running. Click on it to see the live logs.
  3. Verify Deployment:
    • Once the workflow completes, visit https://yourdomain.com in your browser.
    • Test your custom API: https://yourdomain.com/api/hello.
    • SSH into your server and check logs: pm2 logs next-app, sudo tail -f /var/log/nginx/access.log.
    • Check directory structure: ls -l /var/www/your-app should be a symlink. ls -l /var/www/your-app-releases/ should show your new release.

Subsequent Deployments:

Every time you git push origin main (or your configured branch), the GitHub Actions workflow will automatically build your application, transfer it, activate the new release, and restart PM2.


This detailed guide should provide a solid foundation for deploying your mixed web application. Remember to adapt paths, names, and credentials to your specific setup. Good luck!