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
wwwto 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:
- Log in to your domain registrar’s DNS management panel.
- 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
- Host/Name:
- Add another A record (optional, for subdomains):
- Host/Name:
api(if your API is onapi.yourdomain.com) - Value/Points to: Your VPS’s Public IPv4 Address
- Host/Name:
- 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.
- Host/Name:
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.comin 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.
- 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.
- Press Enter for the default file location (
- View your public key:Copy the entire output (starts with
cat ~/.ssh/id_ed25519.pubssh-ed25519and ends with your comment).
Launching Your VPS (Linode/DigitalOcean)
Linode:
- Log in to your Linode Cloud Manager.
- Click “Create Linode”.
- Choose your Distribution (Ubuntu 22.04 LTS or 24.04 LTS).
- Select your Region (choose one geographically close to your users).
- Choose your Linode Plan (e.g., Nanode 1GB).
- Under Add SSH Keys, paste the public key you copied earlier.
- Set a Root Password (as a fallback, though you’ll primarily use SSH keys).
- Click “Create Linode”.
- Note down the Linode’s Public IPv4 Address once it’s provisioned.
DigitalOcean:
- Log in to your DigitalOcean Control Panel.
- Click “Create” -> “Droplets”.
- Choose an Image (Ubuntu 22.04 LTS or 24.04 LTS).
- Select a Region.
- Choose a Droplet Plan (e.g., Basic, 1GB RAM).
- Under Authentication, select “SSH keys”. Add your public SSH key if you haven’t already.
- Click “Create Droplet”.
- 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.
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
usermodcommand adds the user to thesudogroup, allowing them to run commands with superuser privileges.
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_keysConfigure SSH Daemon: Open the SSH configuration file:
sudo nano /etc/ssh/sshd_configFind 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).
Restart SSH service:
sudo systemctl restart sshd⚠ Important: Before closing your current
rootSSH 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 portIf successful, you can safely close the
rootsession. All subsequent server interactions should be asyouruser.
Configuring UFW Firewall
UFW (Uncomplicated Firewall) is an easy-to-use frontend for iptables.
- Allow SSH (on your chosen port):
sudo ufw allow 2222/tcp # Or 22/tcp if you kept the default port - Allow HTTP and HTTPS:(These will allow ports 80 and 443 respectively).
sudo ufw allow http sudo ufw allow https - Enable UFW:Type
sudo ufw enableyand Enter when prompted. - Check UFW status:You should see rules for SSH, HTTP, and HTTPS.
sudo ufw status verbose
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.
Install Fail2Ban:
sudo apt update sudo apt install fail2banCopy default config file:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localIt’s best practice to edit
jail.localasjail.confmight be overwritten on updates.Configure Fail2Ban: Open
jail.local:sudo nano /etc/fail2ban/jail.localFind 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 hasenabled = true. Save and exit.Restart Fail2Ban service:
sudo systemctl restart fail2ban sudo systemctl enable fail2banYou 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.
- 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~/.zshrcif you use zsh).
- Close and reopen your SSH session to make NVM available, or run
- 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 - Verify installation:You should see the installed versions.
node -v npm -v
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.
Install Nginx:
sudo apt update sudo apt install nginx sudo systemctl start nginx sudo systemctl enable nginxCheck 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.Create Nginx Configuration File: We’ll create a new server block for your domain. Replace
yourdomain.comwith your actual domain.sudo nano /etc/nginx/sites-available/yourdomain.comPaste 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_certificateandssl_certificate_keypaths will be generated by Certbot later. Leave them as they are for now. Thereturn 301 https://$host$request_uri;line in the first server block should be commented out until SSL is set up and working.Enable the Nginx Configuration: Create a symbolic link from
sites-availabletosites-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/defaultTest Nginx Configuration:
sudo nginx -tYou should see
syntax is okandtest is successful. If not, check for typos.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.
Install Certbot:
sudo apt update sudo apt install certbot python3-certbot-nginxObtain 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 301line in your Nginx config. - Certbot will then acquire and install your SSL certificates, and configure Nginx to use them.
Verify SSL: Visit
https://yourdomain.comin your browser. You should see a padlock icon indicating a secure connection.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-runYou 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.
Install PM2 globally:
npm install pm2@latest -gStart 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 inserver.jswithin theoutorbuilddirectory, and your Express API (if separate) isapi.js. Create a simple Next.js custom server (e.g.,server.jsin 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.jsfile 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.
List PM2 processes:
pm2 listSave 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 correctpm2executable that NVM installed. The path will be similar to/home/youruser/.nvm/versions/node/vXX.YY.Z/bin. - This command will output a
systemctlcommand you need to run to enable the generated service (e.g.,sudo systemctl enable pm2-youruser). Copy and execute that command.
- The
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.
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
Add the public key to your server’s
authorized_keys: SSH into your VPS asyouruserand add the content ofgithub_actions_deploy_key.pubto/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.
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
- Name:
- Add another secret:
- Name:
SSH_USERNAME - Secret:
youruser(the non-root user you created)
- Name:
- Add another secret (if you changed the SSH port):
- Name:
SSH_PORT - Secret:
2222(or your chosen port)
- Name:
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.
- Deploy the new version of your app to a new directory (the “green” environment).
- Once confirmed, update a symbolic link (
/var/www/your-app->release-xxxx) to point to the new version. - Restart your process manager (
pm2 reload). Thepm2 reloadcommand 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):
- You have multiple identical VPS instances behind a load balancer.
- You deploy the new version to one instance, take it out of the load balancer’s rotation.
- Once updated and tested, put it back in rotation.
- 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.
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.
Note Connection Details:
- After creation, you’ll get a hostname, port, database name, username, and password. These are your application’s
DATABASE_URLcomponents. - 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.
- After creation, you’ll get a hostname, port, database name, username, and password. These are your application’s
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.
Install PostgreSQL:
sudo apt update sudo apt install postgresql postgresql-contribAccess PostgreSQL prompt as the
postgresuser:sudo -i -u postgres psqlCreate 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.
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. Editpg_hba.confto 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-256method is recommended):# TYPE DATABASE USER ADDRESS METHOD host your_app_db your_app_user 127.0.0.1/32 scram-sha-256Save and exit.
Restart PostgreSQL:
sudo systemctl restart postgresqlExample 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.
- Sign up for Cloudflare: Go to cloudflare.com and create an account.
- Add your site: Follow the prompts to add your domain.
- 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
Arecord for@andwww(or other subdomains pointing to your VPS) are present and proxied (orange cloud icon). This means traffic goes through Cloudflare.
- Ensure your
- 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.
- 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/staticor to bypass caching for admin routes.
- SSL/TLS -> Overview:
13. Environment Variables & Secrets Management
Properly managing environment variables and secrets is critical for security and maintainability.
Local Development (
.env): Use a.envfile in your project root (e.g., withdotenvlibrary) 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
.envto 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.Then,
// 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" } }] };pm2 start ecosystem.config.js. - systemd: If you’re using a plain systemd service, define variables in the
[Service]section:⚠ 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,# ... Environment="NODE_ENV=production" Environment="PORT=3000" Environment="DATABASE_URL=postgresql://your_app_user:your_secure_password@localhost:5432/your_app_db" # ...systemdor PM2’s env block is often sufficient if the file itself is secure.
- PM2: You can pass environment variables directly when starting PM2 processes, or use a PM2 ecosystem file.
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
- Access logs (who is accessing your site):
- Application Logs (PM2):
- View PM2 logs:
pm2 logs next-app(replacenext-appwith your app’s name) - View all PM2 logs:
pm2 logs - PM2 also writes logs to files, usually in
~/.pm2/logs/.
- View PM2 logs:
- systemd Journal:
- If using
systemddirectly (or for PM2’s service):journalctl -u your-app.service -f(-ffor follow,-ufor unit).
- If using
- Nginx Logs:
Basic Metrics (CPU, Memory, Disk):
htop: Interactive process viewer (install withsudo 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:
- In Linode Cloud Manager -> “NodeBalancers” -> “Create NodeBalancer”.
- Choose a region.
- Configure a listening port (e.g., 80 and 443).
- 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.pemand/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.
- 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).
- 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:
- In DigitalOcean Control Panel -> “Networking” -> “Load Balancers” -> “Create Load Balancer”.
- Choose a region.
- Choose your Droplets (VPS instances) to add as backend nodes.
- 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.
- 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.
- 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 (
youruserin 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
digorwhatsmydns.netto 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 disablesudo ufw disablefor testing, but re-enable quickly.
- Nginx Configuration Errors:
- Symptom: Nginx fails to start, or shows a 502 Bad Gateway error.
- Fix:
sudo nginx -tto check syntax. Review Nginx logs (/var/log/nginx/error.log). A 502 often means Nginx can’t connect to your Node.js app (checkproxy_passURL and app port).
- Application Crashes:
- Symptom: Your website returns 502/503 errors, or specific routes fail.
- Fix: Check application logs (
pm2 logs next-apporjournalctl -u your-app.service). Usepm2 statusto 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 youryouruserhas appropriate read/write access to your application directory (/var/www/your-app). Nginx typically runs aswww-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_keyis correctly added to GitHub Secrets and your server’sauthorized_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
sudoaccess. - 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)
- Purchase Domain & Configure DNS: Buy
yourdomain.com. In your registrar, set up A records pointingyourdomain.comandwww.yourdomain.comto your VPS IP. Wait for propagation. - Provision VPS: Create an Ubuntu 22.04/24.04 LTS VPS on Linode or DigitalOcean. Add your personal SSH public key during creation.
- 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 - 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 - 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 - Install Nginx:
sudo apt install nginx sudo systemctl start nginx sudo systemctl enable nginx - 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 - 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 - 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 - Provision Managed PostgreSQL (or install self-hosted):
- Managed: Create a PostgreSQL instance on Linode/DigitalOcean. Note down the
DATABASE_URLor 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
- Managed: Create a PostgreSQL instance on Linode/DigitalOcean. Note down the
- 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
- 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 - Add
server.js: Copy theserver.jscontent provided above into your project root. - Create
ecosystem.config.js: Copy the PM2 config into your project root.- Fill in
DATABASE_URLwith your production database connection string. - Fill in
NEXT_PUBLIC_API_URLwith your production domain (https://yourdomain.com/api).
- Fill in
- 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 - 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 - 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. - 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.
- Create GitHub Actions Workflow:
Create the directory
.github/workflows/in your project root. Createdeploy.ymlinside it and paste the GitHub Actions workflow content provided above.
Part 3: First Deployment (CI/CD)
- 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 - 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.
- Verify Deployment:
- Once the workflow completes, visit
https://yourdomain.comin 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-appshould be a symlink.ls -l /var/www/your-app-releases/should show your new release.
- Once the workflow completes, visit
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!