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.
Build auto, deploy manual - full control
Instant rollback to any of last 5 versions
Graceful reload - zero dropped requests
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
- Trigger: Developer pushes to main branch
- Build: GitLab Runner builds Nuxt 4 app (creates .output/)
- Manual Approval: Developer clicks "Deploy" in GitLab UI
- Upload: Artifacts uploaded to timestamped release directory
- Archive Logs: Current logs archived before switch
- Symlink Switch: Atomic update of current โ new release
- Dependencies: pnpm install --prod + Prisma generate/migrate
- PM2 Reload: Graceful cluster reload (zero downtime)
- Health Check: Verify app responds on port
- 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
- 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 .
Prerequisites
Before setting up GitLab CI/CD deployment, ensure you have the following:
1. Server Requirements
- OS: Ubuntu 20.04+ or Debian 11+
- Node.js: 20.x (via nvm)
- PM2: Latest version (globally installed)
- PNPM: Latest version (via corepack)
- Disk Space: Minimum 10GB free
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"
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
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
- 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',
},
],
};
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.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
- Go to GitLab โ CI/CD โ Pipelines
- Watch the build job progress
- Green checkmark = build successful
Step 3: Deploy to Production
- Find the "deploy:production" job (shows play button โถ๏ธ)
- Click the play button to trigger deployment
- Watch logs as it deploys
-
Verify at
https:// {{deploy.domain || 'example.com'}}
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
- Go to GitLab โ CI/CD โ Pipelines
- Find the previous successful pipeline
- Click "Retry" on the deploy job
Manual Deployment (Fallback)
If GitLab CI/CD is unavailable, you can deploy manually:
Option A: Download Artifacts from GitLab
- Go to GitLab โ CI/CD โ Pipelines
- Find pipeline with successful build
- Click Download (๐ฆ) on the build job
- 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:
-
prisma.config.tsis in the wrong location. Prisma 7 only looks for it at the project root โ not insideprisma/. Move it:# Wrong location โ never found by Prisma 7 prisma/prisma.config.ts # Correct โ project root, next to package.json prisma.config.ts -
DATABASE_URLis not in the shell environment. Prisma's TS runner evaluates the config before dotenv loads. Fix: source.envin the shell before calling prisma:set -a && source /var/www/{{deploy.domain || 'example.com'}}/shared/.env && set +a pnpm prisma migrate deploy
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
-
โ
Create server directory structure:
releases/,shared/,logs/ -
โ
Create
shared/.envwith production secrets -
โ
Add
SSH_PRIVATE_KEYto GitLab CI/CD variables -
โ
Create
.gitlab-ci.ymlandecosystem.config.cjs - โ Push to main โ Build runs automatically
- โ Click Deploy in GitLab โ Zero-downtime deployment
- โ Need rollback? Switch symlink + PM2 reload (~5 seconds)