Deploy with Confidence

Automate your entire deployment pipeline. Push to main branch, click deploy in GitLab, and watch your application update with true zero downtime using PM2 cluster mode.

๐Ÿš€
One-Click Deploy

Build auto, deploy manual - full control

๐Ÿ“ฆ
5 Release History

Instant rollback to any of last 5 versions

โšก
PM2 Cluster Mode

Graceful reload - zero dropped requests

0s
Zero Downtime

Symlink switch + cluster reload

How Release-Based Deployment Works

This automated pipeline uses a release-based strategy where each deployment creates a timestamped release directory. A symlink points to the active release, and PM2 cluster mode ensures zero dropped requests during reload.

Directory Structure

/var/www/{{deploy.domain || 'example.com'}}/
โ”œโ”€โ”€ releases/
โ”‚   โ”œโ”€โ”€ 12345_abc123/         # Timestamped releases (pipeline_commit)
โ”‚   โ”œโ”€โ”€ 12346_def456/
โ”‚   โ””โ”€โ”€ 12347_ghi789/         # Latest release
โ”œโ”€โ”€ current โ†’ releases/12347_ghi789/  # Symlink to active release
โ”œโ”€โ”€ shared/
โ”‚   โ”œโ”€โ”€ .env                   # Environment variables (persistent)
โ”‚   โ”œโ”€โ”€ db.sqlite              # Database (persistent)
โ”‚   โ””โ”€โ”€ images/                # User uploads (persistent)
โ””โ”€โ”€ logs/                      # Persistent logs (pm2-logrotate)
    โ”œโ”€โ”€ err.log
    โ”œโ”€โ”€ out.log
    โ”œโ”€โ”€ combined.log
    โ””โ”€โ”€ archives/              # Deployment-specific log archives

Deployment Flow

  1. Trigger: Developer pushes to main branch
  2. Build: GitLab Runner builds Nuxt 4 app (creates .output/)
  3. Manual Approval: Developer clicks "Deploy" in GitLab UI
  4. Upload: Artifacts uploaded to timestamped release directory
  5. Archive Logs: Current logs archived before switch
  6. Symlink Switch: Atomic update of current โ†’ new release
  7. Dependencies: pnpm install --prod + Prisma generate/migrate
  8. PM2 Reload: Graceful cluster reload (zero downtime)
  9. Health Check: Verify app responds on port
  10. Cleanup: Remove old releases (keep last 5)

Pipeline Stages

Stage 1: Build

Installs dependencies, builds Nuxt 4 app, creates deployment artifacts (no .env!)

Stage 2: Test (Optional)

Runs tests - currently a placeholder, enable when tests are ready

Stage 3: Deploy Production (Manual)

Waits for manual approval, then deploys with zero-downtime PM2 reload

Key Benefits:
  • Security: No secrets in CI/CD artifacts or version control
  • Zero-downtime: PM2 cluster mode gracefully replaces workers
  • Easy rollback: Switch symlink to any of last 5 releases
  • Persistent logs: Logs survive deployments, auto-rotated
  • Audit trail: Full deployment history in GitLab

๐ŸŽฏ Personalize This Guide

Enter your server details below to customize all commands throughout this guide. Your values will appear in highlighted text .

Enter your production domain name
Enter application name for PM2
Enter your server username for SSH access
Enter IP address or hostname of your server
Port for production application

Prerequisites

Before setting up GitLab CI/CD deployment, ensure you have the following:

1. Server Requirements

2. Server Directory Setup

Run this once on a fresh server to create the directory structure:

# SSH into server
ssh {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}}

# Create directory structure
BASE="/var/www/{{deploy.domain || 'example.com'}}"
mkdir -p $BASE/releases
mkdir -p $BASE/shared/images
mkdir -p $BASE/logs/archives

# Set permissions
chmod 755 $BASE $BASE/releases $BASE/shared $BASE/logs

3. Create Shared .env File

# Create .env with your production secrets
nano /var/www/{{deploy.domain || 'example.com'}}/shared/.env

Required variables:

# Database โ€” use absolute path (relative paths break with Prisma 7 + shared SQLite)
DATABASE_URL="file:/var/www/{{deploy.domain || 'example.com'}}/shared/db.sqlite"

# Security
SESSION_SECRET="your-random-secret-here"
NODE_ENV="production"
PORT={{deploy.prodport || '3101'}}

# AI Providers (if applicable)
OPENAI_API_KEY="sk-..."
MISTRAL_API_KEY="..."

# Nuxt public runtime config overrides (NUXT_PUBLIC_* are read at runtime,
# overriding values baked in at build time โ€” required for env-specific config)
NUXT_PUBLIC_RECAPTCHA_SITE_KEY="your-recaptcha-v3-site-key"
RECAPTCHA_SECRET="your-recaptcha-v3-secret-key"

# Optional
ADMIN_EMAILS="admin@example.com"
Why absolute DATABASE_URL? Prisma 7 evaluates prisma.config.ts in an isolated runner context. A relative path like file:./prisma/db.sqlite resolves against the runner's working directory, which may differ from your app root. An absolute path is always unambiguous.
# Secure the .env file
chmod 600 /var/www/{{deploy.domain || 'example.com'}}/shared/.env
Security: Never commit .env to version control! It stays on the server only.

4. GitLab CI/CD Variables

Configure these in GitLab: Settings โ†’ CI/CD โ†’ Variables

Variable Value Protected Masked
SSH_PRIVATE_KEY Your private SSH key (entire content) โœ“ โœ— (too long)

Note: DEPLOY_HOST and DEPLOY_USER are set in .gitlab-ci.yml (not secrets).

5. PM2 Log Rotation (Recommended)

# Install pm2-logrotate
pm2 install pm2-logrotate

# Configure (50MB max, keep 30 rotations, compress)
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 30
pm2 set pm2-logrotate:compress true
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'

# Verify
pm2 conf pm2-logrotate

GitLab CI/CD Pipeline Configuration

Create a .gitlab-ci.yml file in your repository root.

Complete .gitlab-ci.yml

# GitLab CI/CD Configuration for Nuxt 4
# Release-Based Zero-Downtime Deployment
#
# Pipeline: build โ†’ test โ†’ deploy (manual trigger)

stages:
  - build
  - test
  - deploy

# Global variables
variables:
  DEPLOY_HOST: "{{deploy.ipaddress || deploy.domain || 'your-server.com'}}"
  DEPLOY_USER: "{{deploy.sudouser || 'deploy'}}"
  BASE: "/var/www/{{deploy.domain || 'example.com'}}"
  NODE_VERSION: "20"
  PNPM_HOME: ".pnpm-store"

# Cache for faster builds
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .pnpm-store
    - node_modules/.cache

# Build Stage
# Creates deployment artifacts (NO .env - security!)
build:
  stage: build
  image: node:${NODE_VERSION}
  timeout: 10m
  before_script:
    - npm install -g pnpm@latest
  script:
    - echo "๐Ÿ—๏ธ  Building Nuxt application..."
    - rm -rf node_modules .nuxt .output
    - pnpm install --no-frozen-lockfile
    - pnpm run build
    - test -d .output/server && echo 'โœ… Build verified' || (echo 'โŒ Build failed' && exit 1)
    - echo "๐Ÿ“ฆ Build complete"
    - ls -la .output/
  artifacts:
    expire_in: 1 week
    paths:
      - .output/
      - prisma/
      - prisma.config.ts  # Must be at project root โ€” Prisma 7 will not find it inside prisma/
      - scripts/
      - ecosystem.config.cjs
      - package.json
      - pnpm-lock.yaml
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  tags:
    - docker

# Test Stage (Optional)
test:
  stage: test
  image: node:${NODE_VERSION}
  before_script:
    - npm install -g pnpm@latest
  script:
    - echo "๐Ÿงช Running tests..."
    - pnpm install --no-frozen-lockfile
    # - pnpm run test  # Uncomment when tests are ready
    - echo "โœ… Tests passed (placeholder)"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  tags:
    - docker
  allow_failure: true

# Deploy to Production (Manual Trigger)
deploy:production:
  stage: deploy
  image: alpine:3.18
  needs:
    - build
  only:
    - main
  environment:
    name: production
    url: https://{{deploy.domain || 'example.com'}}
  before_script:
    - apk add --no-cache openssh-client rsync bash
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts
  script:
    # Generate unique release identifier
    - RELEASE="${CI_PIPELINE_ID}_${CI_COMMIT_SHORT_SHA}"
    - echo "๐Ÿš€ Deploying release $RELEASE"

    # Create release directory
    - ssh $DEPLOY_USER@$DEPLOY_HOST "mkdir -p $BASE/releases/$RELEASE"

    # Upload artifacts
    - echo "๐Ÿ“ฆ Uploading build artifacts..."
    - rsync -az --delete .output/ $DEPLOY_USER@$DEPLOY_HOST:$BASE/releases/$RELEASE/.output/
    - rsync -az --delete prisma/  $DEPLOY_USER@$DEPLOY_HOST:$BASE/releases/$RELEASE/prisma/
    - rsync -az --delete scripts/ $DEPLOY_USER@$DEPLOY_HOST:$BASE/releases/$RELEASE/scripts/
    - rsync -az package.json pnpm-lock.yaml ecosystem.config.cjs $DEPLOY_USER@$DEPLOY_HOST:$BASE/releases/$RELEASE/

    # Execute deployment on server
    - |
      ssh $DEPLOY_USER@$DEPLOY_HOST "
        set -e
        export BASE=$BASE
        export RELEASE=$RELEASE
        export NVM_DIR=\"\$HOME/.nvm\"
        [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\"

        echo '==> Using Node.js'
        nvm use 20
        node --version

        echo '==> Archiving current logs'
        TIMESTAMP=\$(date +%Y%m%d_%H%M%S)
        ARCHIVE_DIR=\$BASE/logs/archives/\$RELEASE
        mkdir -p \$ARCHIVE_DIR
        [ -f \$BASE/logs/err.log ] && cp \$BASE/logs/err.log \$ARCHIVE_DIR/err_\$TIMESTAMP.log
        [ -f \$BASE/logs/out.log ] && cp \$BASE/logs/out.log \$ARCHIVE_DIR/out_\$TIMESTAMP.log
        [ -f \$BASE/logs/combined.log ] && cp \$BASE/logs/combined.log \$ARCHIVE_DIR/combined_\$TIMESTAMP.log
        gzip \$ARCHIVE_DIR/*.log 2>/dev/null || true

        echo '==> Activating release: \$RELEASE'
        ln -sfn \$BASE/releases/\$RELEASE \$BASE/current

        echo '==> Setting up shared symlinks'
        ln -sf \$BASE/shared/.env \$BASE/current/.env
        mkdir -p \$BASE/current/.data
        ln -sfn \$BASE/shared/images \$BASE/current/.data/images

        echo '==> Installing production dependencies'
        cd \$BASE/current
        corepack enable >/dev/null 2>&1 || true
        pnpm install --prod --frozen-lockfile

        echo '==> Generating Prisma client'
        pnpm dlx prisma generate

        echo '==> Running database migrations'
        # Source .env so Prisma 7 sees DATABASE_URL via process.env.
        # Prisma 7 evaluates prisma.config.ts in an isolated TS runner that does NOT
        # execute dotenv/config โ€” shell-level export is the only reliable workaround.
        set -a && source \$BASE/shared/.env && set +a
        pnpm dlx prisma migrate deploy

        echo '==> Reloading PM2 (zero-downtime)'
        # Source .env again so NUXT_PUBLIC_* vars reach the Node process.
        # PM2 env_file support is unreliable across versions; explicit export wins.
        set -a && source \$BASE/shared/.env && set +a
        pm2 reload {{deploy.appname || 'my-app'}} --update-env || {
          echo 'First deployment - starting PM2'
          pm2 start ecosystem.config.cjs --env production
        }
        pm2 save

        echo '==> Verifying deployment'
        sleep 3
        if curl -f -I http://127.0.0.1:{{deploy.prodport || '3101'}}/ > /dev/null 2>&1; then
          echo 'โœ… Application is responding'
          pm2 status
        else
          echo 'โš ๏ธ  WARNING: Application may not be responding'
          pm2 logs {{deploy.appname || 'my-app'}} --lines 20 --nostream
          exit 1
        fi

        echo '==> Cleaning up old releases (keep last 5)'
        cd \$BASE/releases
        ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true

        echo '==> Cleaning up old log archives (keep last 10)'
        cd \$BASE/logs/archives
        ls -1dt */ | tail -n +11 | xargs rm -rf 2>/dev/null || true

        echo 'โœ… Deployment complete!'
      "
  after_script:
    - echo "โœ… Deployed release ${CI_PIPELINE_ID}_${CI_COMMIT_SHORT_SHA}"
    - echo "๐Ÿ”— Production https://{{deploy.domain || 'example.com'}}"
  when: manual  # Require manual approval
๐Ÿ’ก Key Features:
  • Build runs automatically on push to main
  • Deploy requires manual click (safety gate)
  • No .env in artifacts (security)
  • Symlinks for shared resources (.env, images)
  • PM2 cluster reload for zero downtime
  • Automatic cleanup (5 releases, 10 log archives)

PM2 Ecosystem Configuration

Create ecosystem.config.cjs in your repository root.

// ecosystem.config.cjs - PM2 Configuration
// For release-based zero-downtime deployment

module.exports = {
  apps: [
    {
      name: '{{deploy.appname || 'my-app'}}',
      script: './.output/server/index.mjs',
      cwd: '/var/www/{{deploy.domain || 'example.com'}}/current',

      // Cluster mode for zero-downtime reload
      instances: 2,  // Or 'max' for all CPUs
      exec_mode: 'cluster',

      // Use shared .env (symlinked)
      env_file: '/var/www/{{deploy.domain || 'example.com'}}/shared/.env',

      // Persistent logs (survive deployments)
      error_file: '/var/www/{{deploy.domain || 'example.com'}}/logs/err.log',
      out_file: '/var/www/{{deploy.domain || 'example.com'}}/logs/out.log',
      log_file: '/var/www/{{deploy.domain || 'example.com'}}/logs/combined.log',

      // Production environment
      env_production: {
        NODE_ENV: 'production',
        PORT: {{deploy.prodport || '3101'}},
      },

      // Graceful reload settings
      wait_ready: true,
      listen_timeout: 10000,
      kill_timeout: 5000,

      // Auto-restart on crash
      max_restarts: 10,
      min_uptime: '10s',
    },
  ],
};
Why Cluster Mode?

With instances: 2 and exec_mode: 'cluster' , PM2 runs multiple workers. During pm2 reload , it gracefully replaces workers one at a time, ensuring zero dropped requests.

Prisma 7 Configuration

Prisma 7 removed the url field from schema.prisma and requires the database URL to be provided exclusively via a prisma.config.ts file. Getting this wrong is the most common cause of failed deployments.

Critical: Config File Location

โš  Prisma 7 only looks for prisma.config.ts at the project root. Placing it inside prisma/prisma.config.ts means it is never found by prisma migrate deploy , causing a cryptic error even when your DATABASE_URL is correctly set.
# Correct โ€” at project root (next to package.json)
prisma.config.ts          โœ…
prisma/schema.prisma

# Wrong โ€” Prisma 7 will NOT find this
prisma/prisma.config.ts   โŒ

Correct prisma.config.ts

Place this file at the project root . Use process.env.DATABASE_URL directly โ€” do not use env() from prisma/config , which runs in an isolated context that may not read process.env correctly:

// prisma.config.ts โ€” at project root
import "dotenv/config";
import { defineConfig } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: process.env.DATABASE_URL!,   // โœ… direct process.env read
    // url: env("DATABASE_URL"),      // โŒ Prisma's env() may not resolve correctly
  },
});

schema.prisma datasource block

In Prisma 7 the url field is removed from the datasource block in the schema:

// prisma/schema.prisma
datasource db {
  provider = "sqlite"
  // No url field โ€” Prisma 7 reads it from prisma.config.ts only
}

Why source .env before prisma migrate deploy?

Prisma 7's internal TypeScript runner evaluates prisma.config.ts before the import "dotenv/config" side-effect can execute, so process.env.DATABASE_URL is still empty unless you export it at the shell level first. The deploy commands above include set -a && source shared/.env && set +a before each prisma migrate deploy call for exactly this reason.

Artifacts to include in .gitlab-ci.yml

Make sure prisma.config.ts is included in your build artifacts (it lives at the root, not inside prisma/ ):

artifacts:
  paths:
    - .output/
    - prisma/           # schema + migrations
    - prisma.config.ts  # โœ… root-level config โ€” don't forget this!
    - scripts/
    - ecosystem.config.cjs
    - package.json
    - pnpm-lock.yaml

Deployment Workflow

Step 1: Push Your Changes

# Make changes, commit, push
git add .
git commit -m "Add new feature"
git push origin main

# GitLab CI automatically starts building

Step 2: Monitor Build

  1. Go to GitLab โ†’ CI/CD โ†’ Pipelines
  2. Watch the build job progress
  3. Green checkmark = build successful

Step 3: Deploy to Production

  1. Find the "deploy:production" job (shows play button โ–ถ๏ธ)
  2. Click the play button to trigger deployment
  3. Watch logs as it deploys
  4. Verify at https:// {{deploy.domain || 'example.com'}}
That's it! Your changes are now live with zero downtime. PM2 gracefully replaced workers while the symlink pointed to the new release.

Rollback

If something goes wrong, rollback is instant (symlink switch + PM2 reload).

Option 1: Quick Rollback Script

# SSH to server
ssh {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}}

# List available releases
ls -lt /var/www/{{deploy.domain || 'example.com'}}/releases/

# Get current version
CURRENT=$(readlink /var/www/{{deploy.domain || 'example.com'}}/current | sed 's|.*/||')
echo "Current: $CURRENT"

# Get previous version
PREVIOUS=$(ls -t /var/www/{{deploy.domain || 'example.com'}}/releases/ | grep -v "^${CURRENT}$" | head -1)
echo "Rolling back to: $PREVIOUS"

# Atomic rollback
ln -sfn "/var/www/{{deploy.domain || 'example.com'}}/releases/$PREVIOUS" /var/www/{{deploy.domain || 'example.com'}}/current
pm2 reload {{deploy.appname || 'my-app'}} --update-env

# Verify
curl -I http://127.0.0.1:{{deploy.prodport || '3101'}}/
echo "โœ… Rolled back to $PREVIOUS"

Option 2: Re-deploy Previous Pipeline

  1. Go to GitLab โ†’ CI/CD โ†’ Pipelines
  2. Find the previous successful pipeline
  3. Click "Retry" on the deploy job
Rollback Time: Manual symlink rollback: ~5 seconds. GitLab re-deploy: 3-5 minutes (full process).

Manual Deployment (Fallback)

If GitLab CI/CD is unavailable, you can deploy manually:

Option A: Download Artifacts from GitLab

  1. Go to GitLab โ†’ CI/CD โ†’ Pipelines
  2. Find pipeline with successful build
  3. Click Download (๐Ÿ“ฆ) on the build job
  4. Extract artifacts.zip
# Upload to server
cd ~/Downloads/artifacts
RELEASE="$(date +%Y%m%d_%H%M%S)_manual"
ssh {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}} "mkdir -p /var/www/{{deploy.domain || 'example.com'}}/releases/$RELEASE"

rsync -az --delete .output/ {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}}:/var/www/{{deploy.domain || 'example.com'}}/releases/$RELEASE/.output/
rsync -az --delete prisma/ {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}}:/var/www/{{deploy.domain || 'example.com'}}/releases/$RELEASE/prisma/
rsync -az package.json pnpm-lock.yaml ecosystem.config.cjs {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}}:/var/www/{{deploy.domain || 'example.com'}}/releases/$RELEASE/

Option B: Build Locally

# Build locally
pnpm install
pnpm run build

# Then upload as above

Activate on Server

ssh {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}}
cd /var/www/{{deploy.domain || 'example.com'}}

# Switch symlink
ln -sfn /var/www/{{deploy.domain || 'example.com'}}/releases/$RELEASE /var/www/{{deploy.domain || 'example.com'}}/current

# Setup shared symlinks
ln -sf /var/www/{{deploy.domain || 'example.com'}}/shared/.env /var/www/{{deploy.domain || 'example.com'}}/current/.env
mkdir -p /var/www/{{deploy.domain || 'example.com'}}/current/.data
ln -sfn /var/www/{{deploy.domain || 'example.com'}}/shared/images /var/www/{{deploy.domain || 'example.com'}}/current/.data/images

# Install and reload
cd /var/www/{{deploy.domain || 'example.com'}}/current
pnpm install --prod --frozen-lockfile
pnpm dlx prisma generate
pnpm dlx prisma migrate deploy
pm2 reload {{deploy.appname || 'my-app'}} --update-env
pm2 save

# Verify
pm2 status
curl -I http://127.0.0.1:{{deploy.prodport || '3101'}}/

Troubleshooting

Pipeline Fails at Build Stage

# Test build locally first
pnpm install
pnpm run build

# Check for TypeScript errors
pnpm run typecheck

# Check .output/ was created
ls -la .output/server/

SSH Connection Issues

# Verify SSH key in GitLab variables
# Must include -----BEGIN/END----- lines
# No extra spaces or newlines

# Test SSH manually
ssh -i ~/.ssh/your_key {{deploy.sudouser || 'deploy'}}@{{deploy.ipaddress || 'your-server'}}

PM2 Reload Fails

# Check PM2 status
pm2 status
pm2 logs {{deploy.appname || 'my-app'}} --lines 50

# Common issues:
# - Port already in use
# - Missing .env variables
# - Database connection issues

# Restart from scratch
pm2 delete {{deploy.appname || 'my-app'}}
cd /var/www/{{deploy.domain || 'example.com'}}/current
pm2 start ecosystem.config.cjs --env production
pm2 save

Health Check Fails

# Check if app is responding
curl -I http://127.0.0.1:{{deploy.prodport || '3101'}}/

# Check PM2 logs for errors
pm2 logs {{deploy.appname || 'my-app'}} --err --lines 50

# Check .env symlink
ls -la /var/www/{{deploy.domain || 'example.com'}}/current/.env

Disk Space Issues

# Check disk usage
df -h
du -sh /var/www/{{deploy.domain || 'example.com'}}/*

# Manual cleanup (if automatic cleanup failed)
cd /var/www/{{deploy.domain || 'example.com'}}/releases
ls -1dt */ | tail -n +3 | xargs rm -rf

Prisma: "datasource.url property is required"

This error means Prisma 7 could not resolve the database URL. There are two distinct root causes:

  1. prisma.config.ts is in the wrong location. Prisma 7 only looks for it at the project root โ€” not inside prisma/ . Move it:
    # Wrong location โ€” never found by Prisma 7
    prisma/prisma.config.ts
    
    # Correct โ€” project root, next to package.json
    prisma.config.ts
  2. DATABASE_URL is not in the shell environment. Prisma's TS runner evaluates the config before dotenv loads. Fix: source .env in the shell before calling prisma:
    set -a && source /var/www/{{deploy.domain || 'example.com'}}/shared/.env && set +a
    pnpm prisma migrate deploy
Tip: prisma generate succeeds without a URL (it only reads the schema), masking the missing config problem until migrate deploy runs.

NUXT_PUBLIC_* variables not available in the running app

Nuxt's public runtimeConfig values are baked in as empty strings at build time and overridden at runtime by NUXT_PUBLIC_* env vars โ€” but only if those vars actually reach the Node process. PM2's env_file ecosystem option is unreliable across PM2 versions. Symptoms: reCAPTCHA token is undefined, feature flags are always false , public API URLs are empty.

# Diagnose: check what PM2 actually sees
pm2 env {{deploy.appname || 'my-app'}} | grep NUXT_PUBLIC

# Fix: source .env before pm2 reload so vars are in the shell environment
set -a && source /var/www/{{deploy.domain || 'example.com'}}/shared/.env && set +a
pm2 reload {{deploy.appname || 'my-app'}} --update-env

The deploy commands in this guide already include this source call before every pm2 reload .

rsync silently skips changed files

rsync compares files by modification time and size . If an edit happens to leave the file size unchanged (e.g. swapping two equal-length strings), rsync considers the destination up-to-date and transfers nothing. Use --checksum to force a content comparison, or scp which always copies:

# Force transfer by content hash instead of mtime+size
rsync -az --checksum file.ts user@server:/path/

# Or just use scp (always copies unconditionally)
scp file.ts user@server:/path/

Also note: rsync from Windows does not preserve Unix execute bits. Always chmod +x shell scripts after uploading, or add --chmod=755 to the rsync command.

Best Practices

Security

  • Never commit .env to version control
  • Use protected GitLab variables
  • Rotate SSH keys regularly
  • Use dedicated deploy user (not root)
  • Set .env permissions to 600

Operations

  • Monitor PM2 status after deploys
  • Review logs for errors
  • Keep database backups
  • Test rollback procedure
  • Document custom configurations

Why This Approach Wins

Feature Traditional Deploy Release-Based Deploy
Downtime 10-30 seconds 0 seconds
Rollback Time 3-5 minutes (rebuild) 5 seconds
Rollback Options 1 (previous) 5 releases
Secrets in CI/CD Often exposed Never
Log Persistence Lost on deploy Archived

Summary

  1. โœ… Create server directory structure: releases/ , shared/ , logs/
  2. โœ… Create shared/.env with production secrets
  3. โœ… Add SSH_PRIVATE_KEY to GitLab CI/CD variables
  4. โœ… Create .gitlab-ci.yml and ecosystem.config.cjs
  5. โœ… Push to main โ†’ Build runs automatically
  6. โœ… Click Deploy in GitLab โ†’ Zero-downtime deployment
  7. โœ… Need rollback? Switch symlink + PM2 reload (~5 seconds)
Bottom Line: This release-based approach transforms deployment from a risky, error-prone process into a reliable, auditable, one-click operation with instant rollback capability. Deploy with confidence!