Skip to main content

Next.js Deployment with GitLab CI/CD: From Server Setup to Automation

Constantin Potapov
30 min

Complete guide to setting up automated Next.js deployment to your own server via GitLab CI/CD. PM2 for zero-downtime, Nginx Proxy Manager for domain management, secrets management, and multi-environment setup.

Next.js Deployment with GitLab CI/CD: From Server Setup to Automation

Short version

Vercel and Netlify come and go. There is no local Vercel.

Stack: GitLab CI, your server, PM2 cluster, Nginx Proxy Manager. Push to main, the site stays up, SSL issues itself. One IP, several domains, a predictable bill.

GitLab CI → git pull → npm ci → npm run build → PM2 reload → done

For production add backup, health check, rollback, staging. If you do not want to turn the screws: Coolify, Dokploy, CapRover.

Two hours for the minimum. Half a day for the full stack.

Why your own server

potapov.me runs this way. Not out of love for systemd. Because the platform must not vanish on a Friday.

Below is the full stack. The personal site runs the minimum: pull, ci, build, PM2 reload. Take the rest as a checklist, not a religion.

Solution Architecture

System Components

┌─────────────┐      ┌──────────────┐      ┌──────────────────────┐
│   GitLab    │─────▶│ GitLab Runner│─────▶│   Server Cluster     │
│ (Git + CI)  │      │   (Build)    │      │                      │
└─────────────┘      └──────────────┘      │ ┌────────────────┐   │
      │                                    │ │ Nginx Proxy    │   │
      │ 1. Push code                       │ │ Manager (NPM)  │   │
      │ 2. Trigger pipeline                │ └────────┬───────┘   │
      │ 3. Build & test                    │          │           │
      │ 4. Deploy via SSH ─────────────────┼──────────┤           │
      │ 5. Health check                    │ ┌────────▼───────┐   │
      │ 6. Rollback if needed              │ │  Next.js App   │   │
      │                                    │ │  (PM2 Cluster) │   │
      │                                    │ └────────────────┘   │
      └────────────────────────────────────┴──────────────────────┘
                                              1 External IP
                                              Multiple Apps/Servers

Why this stack?

  1. GitLab CI: built into GitLab, free for self-hosted, powerful YAML DSL
  2. PM2: production process manager with cluster mode for zero-downtime reload
  3. Nginx Proxy Manager: convenient web interface for managing reverse proxy, SSL certificates, and multiple domains on one IP
  4. Ubuntu 24.04: LTS release with long-term support

Why Nginx Proxy Manager instead of plain Nginx?

If you have one external IP address and multiple servers/applications (like my case: a whole cluster), managing through Nginx configs becomes inconvenient. Nginx Proxy Manager (NPM) solves this:

  • Web interface for domain and proxy management
  • Automatic SSL certificates via Let's Encrypt
  • Easy addition of new domains without editing configs
  • Support for proxy hosts, streams, redirects
  • Access lists and bot protection out of the box

For simple cases (1 server, 1 domain), you can use plain Nginx: the principles are the same.

Deployment Workflow

  1. Developer pushes code to main or develop branch
  2. GitLab CI runs pipeline:
    • Install: install dependencies with caching
    • Lint: code checking (ESLint, TypeScript)
    • Test: run tests (optional)
    • Build: production Next.js build
    • Deploy: deploy to server via SSH
  3. Server receives new code and:
    • Backs up current version
    • Installs dependencies
    • Builds application (or copies artifact)
    • Executes pm2 reload for zero-downtime restart
    • Checks health endpoint
  4. Rollback automatically executes on errors

Server

Hardware Requirements

For comfortable Next.js application operation, I recommend:

  • CPU: 2 vCPU (minimum 1 vCPU, but build will be slow)
  • RAM: 2 GB (minimum 1 GB + 2 GB swap for build)
  • Disk: 20 GB SSD (10 GB minimum for code, node_modules, .next, logs)
  • OS: Ubuntu 24.04 LTS
  • Network:
    • Incoming: 22 (SSH), 80 (HTTP), 443 (HTTPS)
    • Outgoing: 443 (npm, git, certbot)

Important: If you have less than 2 GB RAM, definitely configure at least 2 GB swap. Next.js build with Turbopack is memory-intensive.

Step 1: Basic Server Setup and Security

Connect to server via SSH and update the system:

# System update
sudo apt update && sudo apt upgrade -y
 
# Install basic packages
sudo apt install -y git curl ufw fail2ban build-essential
 
# Firewall setup
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status

SSH Hardening (Security Enhancement)

Important for production: These settings will protect your server from unauthorized access. Configure before exposing the server to the internet.

# 1. Create SSH key on local machine (if you don't have one yet)
# Run on LOCAL machine, not on server!
ssh-keygen -t ed25519 -C "your_email@example.com"
 
# 2. Copy public key to server
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@your_server_ip
 
# 3. Verify you can login with key
ssh user@your_server_ip
 
# 4. Disable password login (SSH keys only)
sudo nano /etc/ssh/sshd_config
 
# Find and change these lines:
# PasswordAuthentication no
# PubkeyAuthentication yes
# PermitRootLogin no
# ChallengeResponseAuthentication no
 
# 5. Restart SSH
sudo systemctl restart sshd
 
# 6. DON'T CLOSE current session! Open new terminal window
# and verify you can login with key

Fail2Ban for Brute-force Protection

# Installation (already installed above)
sudo apt install -y fail2ban
 
# Create local configuration
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
 
# Configure basic SSH protection
sudo tee /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
destemail = your_email@example.com
sendername = Fail2Ban
 
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
EOF
 
# Start Fail2Ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
 
# Check status
sudo fail2ban-client status
sudo fail2ban-client status sshd

What Fail2Ban does:

  • Analyzes SSH logs
  • Bans IP for 1 hour after 5 failed login attempts in 10 minutes
  • Protects against brute-force attacks

Firewall Configuration (UFW)

More detailed port configuration:

# Reset rules (if you need to start from scratch)
sudo ufw --force reset
 
# Default policy: deny all incoming, allow outgoing
sudo ufw default deny incoming
sudo ufw default allow outgoing
 
# Allow SSH (IMPORTANT: do this before enable!)
sudo ufw allow 22/tcp comment 'SSH'
 
# Allow HTTP/HTTPS for NPM or Nginx
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
 
# If using NPM, allow admin panel (only for your IP!)
# sudo ufw allow from YOUR_IP to any port 81 comment 'NPM Admin'
 
# Rate limit SSH connections (DDoS protection)
sudo ufw limit 22/tcp
 
# Enable firewall
sudo ufw enable
 
# Check rules
sudo ufw status numbered

Caution! Before ufw enable make sure you allowed SSH port (22), otherwise you'll lose server access!

Step 2: Installing Node.js 20 LTS

Next.js 15 requires Node.js >= 18.17, recommended 20 LTS:

# Install Node.js 20 via NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
 
# Check versions
node -v  # should be >= v20.x
npm -v   # should be >= 10.x
 
# Configure npm for production
npm config set loglevel error

Alternative: You can use nvm (Node Version Manager) to manage Node.js versions, especially if you need multiple versions on one server.

Step 3: PM2 Installation and Configuration

PM2 is a production process manager with cluster mode support for zero-downtime deployment.

# Install PM2 globally
sudo npm install -g pm2
 
# Configure PM2 autostart on reboot
pm2 startup systemd -u $USER --hp $HOME
# Command will output a string with sudo: execute it
 
# Check systemd service
sudo systemctl status pm2-$USER

Why PM2 instead of systemd directly?

  • Cluster mode with automatic load balancing
  • Graceful reload without downtime (smooth switching between instances)
  • Built-in monitoring (CPU, RAM, logs)
  • Automatic restart on crash
  • Log rotation out of the box
  • Simple API for process management

Step 4: Nginx Proxy Manager Setup

Nginx Proxy Manager (NPM) is a web interface for managing Nginx as a reverse proxy. Especially convenient when you have one external IP and multiple applications/servers.

Installation via Docker Compose

# Create directory for NPM
mkdir -p ~/nginx-proxy-manager
cd ~/nginx-proxy-manager
 
# Create docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
  app:
    image: 'jc21/nginx-proxy-manager:latest'
    restart: unless-stopped
    ports:
      - '80:80'     # HTTP
      - '443:443'   # HTTPS
      - '81:81'     # Admin UI
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    environment:
      DB_SQLITE_FILE: "/data/database.sqlite"
EOF
 
# Start NPM
docker-compose up -d
 
# Check status
docker-compose ps

Initial Setup

  1. Open http://YOUR_SERVER_IP:81 in browser
  2. Login with default credentials:
    • Email: admin@example.com
    • Password: changeme
  3. Change email and password on first login

Adding Proxy Host for Next.js

In NPM web interface:

  1. Hosts → Proxy Hosts → Add Proxy Host

  2. Details tab:

    • Domain Names: potapov.me, www.potapov.me
    • Scheme: http
    • Forward Hostname / IP: localhost (or Next.js server IP)
    • Forward Port: 3000
    • ✅ Cache Assets
    • ✅ Block Common Exploits
    • ✅ Websockets Support
  3. SSL tab:

    • ✅ SSL Certificate: Request a new SSL Certificate
    • ✅ Force SSL
    • ✅ HTTP/2 Support
    • ✅ HSTS Enabled
    • Email: your email for Let's Encrypt
    • ✅ I Agree to the Let's Encrypt Terms of Service
  4. Advanced (optional):

# Custom settings for Next.js
location /_next/static/ {
    proxy_cache_valid 200 60m;
    add_header Cache-Control "public, immutable";
}
 
location /api/health {
    access_log off;
}
 
# Timeouts for SSR
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
  1. Save

NPM automatically:

  • Configures reverse proxy to your application
  • Obtains SSL certificate from Let's Encrypt
  • Sets up automatic certificate renewal
  • Enables HTTP to HTTPS redirect

NPM advantages in my case:

I have one external IP address and a whole cluster of servers with different applications. NPM allows:

  • Manage all domains from one place
  • Add new applications in a minute via UI
  • Don't edit configs manually
  • Automatically manage SSL for all domains
  • Configure access lists, rate limiting, redirects

If you have a simple configuration (1 server, 1-2 domains), you can use plain Nginx: the proxying principles are the same.

Alternative: Plain Nginx

If you prefer classic Nginx, example configuration:

# /etc/nginx/sites-available/potapov.me
server {
    listen 80;
    server_name potapov.me www.potapov.me;
 
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 60s;
    }
}

Then use Certbot for SSL:

sudo certbot --nginx -d potapov.me -d www.potapov.me

Step 5: Creating Directory Structure

# Create application directory
sudo mkdir -p /var/www/potapov.me
sudo chown -R $USER:$USER /var/www/potapov.me
 
# Create directories for logs and backups
mkdir -p /var/www/potapov.me/{logs,backups}
 
# Create .env file (will fill via GitLab CI later)
touch /var/www/potapov.me/.env

PM2

PM2 Ecosystem file is a configuration for managing the application in production.

Ecosystem file is stored in the repository and defines startup parameters: number of instances, environment variables, log paths, memory limits, etc.

Create ecosystem.config.cjs in project root:

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: "potapov-me",
      cwd: "/var/www/potapov.me",
      script: "node_modules/next/dist/bin/next",
      args: "start -p 3000",
 
      // Environment variables
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
 
      // Cluster mode for zero-downtime reload
      exec_mode: "cluster",
      instances: 2, // or 'max' to use all CPU cores
 
      // Auto-restart settings
      autorestart: true,
      max_restarts: 10,
      min_uptime: "10s",
      max_memory_restart: "500M", // Restart on memory limit
 
      // Logging
      out_file: "./logs/pm2-out.log",
      error_file: "./logs/pm2-error.log",
      log_date_format: "YYYY-MM-DD HH:mm:ss Z",
      merge_logs: true,
 
      // Graceful shutdown
      kill_timeout: 5000,
      wait_ready: true,
      listen_timeout: 10000,
    },
  ],
};

Key parameters:

  • exec_mode: 'cluster': run multiple instances for balancing and zero-downtime reload
  • instances: 2: number of processes (recommended 2-4 for typical VPS)
  • max_memory_restart: automatic restart on memory leaks
  • wait_ready: wait for ready signal from application (requires process.send('ready') in code)
  • kill_timeout: time for graceful shutdown (finishing current requests)

Pro tip: For Next.js in production mode, 2-4 instances are enough. More isn't always better, especially on low-resource VPS.

Adding Health Check Endpoint

Create API route for application health check:

// app/api/health/route.ts
import { NextResponse } from "next/server";
 
export async function GET() {
  const health = {
    status: "ok",
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    environment: process.env.NODE_ENV,
  };
 
  return NextResponse.json(health, { status: 200 });
}

This endpoint is used for:

  • Availability check after deployment
  • Monitoring via external services (UptimeRobot, Pingdom)
  • Load balancer health checks

GitLab CI

Now let's set up automatic deployment via GitLab CI.

CI/CD Architecture

GitLab CI uses Runners: agents that execute pipeline tasks. Types:

  1. Shared Runners: provided by GitLab.com for free (400 minutes/month limit)
  2. Specific Runners: your own runner on any server
  3. Group/Project Runners: for a group of projects or specific repository

For this guide, we'll use Shared Runners from GitLab.com for building and deploy to your server via SSH. If you need more control: set up a Specific Runner on your server.

Step 1: SSH Access Setup for CI

GitLab CI will connect to the server via SSH for deployment. Let's configure SSH keys:

# On server: create deploy user (optional)
sudo adduser deployer
sudo usermod -aG sudo deployer
 
# Add to sudoers for commands without password (if needed)
# For NPM usually not required as it works via Docker
# For plain Nginx:
# echo "deployer ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx, /usr/bin/pm2" | sudo tee /etc/sudoers.d/deployer
 
# Switch to deployer
su - deployer
 
# Create SSH keys (on local machine or in GitLab CI/CD Settings)
ssh-keygen -t ed25519 -C "gitlab-ci@potapov.me" -f ~/.ssh/gitlab_ci_ed25519
 
# Copy public key to server
# On server (under deployer):
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
# Paste contents of gitlab_ci_ed25519.pub
chmod 600 ~/.ssh/authorized_keys

Step 2: Adding Secrets to GitLab

Go to GitLab: Settings → CI/CD → Variables and add:

KeyValueProtectedMasked
SSH_PRIVATE_KEYContents of gitlab_ci_ed25519✅✅
SSH_HOSTServer IP or domain✅❌
SSH_USERdeployer✅❌
DEPLOY_PATH/var/www/potapov.me❌❌
NODE_ENVproduction❌❌

Security: Enable "Protected" for production variables (available only in protected branches) and "Masked" for secrets (not shown in logs).

Step 3: Creating .gitlab-ci.yml

Create .gitlab-ci.yml file in project root:

# .gitlab-ci.yml
# GitLab CI/CD pipeline for Next.js deployment
 
# Global settings
image: node:20-alpine
 
# Pipeline stages
stages:
  - install
  - lint
  - test
  - build
  - deploy
  - healthcheck
  - rollback
 
# Cache node_modules for speedup
cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/
    - .npm/
 
# Environment variables
variables:
  npm_config_cache: "$CI_PROJECT_DIR/.npm"
  NEXT_TELEMETRY_DISABLED: "1"
 
# Template for Node.js jobs
.node_job:
  before_script:
    - node -v
    - npm -v
 
# === STAGE: Install Dependencies ===
install_dependencies:
  extends: .node_job
  stage: install
  script:
    - echo "Installing dependencies..."
    - npm ci --prefer-offline --no-audit
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour
  only:
    - main
    - develop
    - merge_requests
 
# === STAGE: Lint ===
lint:
  extends: .node_job
  stage: lint
  dependencies:
    - install_dependencies
  script:
    - echo "Running ESLint..."
    - npm run lint
  only:
    - main
    - develop
    - merge_requests
 
# === STAGE: Build ===
build:
  extends: .node_job
  stage: build
  dependencies:
    - install_dependencies
  script:
    - echo "Building Next.js application..."
    - npm run build
  artifacts:
    paths:
      - .next/
      - public/
    expire_in: 1 hour
  only:
    - main
    - develop
 
# === STAGE: Deploy to Production ===
deploy_production:
  stage: deploy
  image: alpine:latest
  dependencies:
    - build
  before_script:
    # Install SSH client
    - apk add --no-cache openssh-client bash git
 
    # Setup SSH
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
 
  script:
    - echo "Deploying to production server..."
 
    # Create backup of current version
    - |
      ssh $SSH_USER@$SSH_HOST "
        cd $DEPLOY_PATH &&
        if [ -d .next ]; then
          echo 'Creating backup...' &&
          BACKUP_NAME=backup-\$(date +%Y%m%d-%H%M%S) &&
          mkdir -p backups &&
          tar -czf backups/\$BACKUP_NAME.tar.gz .next package.json package-lock.json &&
          echo 'Backup created: '\$BACKUP_NAME &&
 
          # Keep only last 5 backups
          cd backups &&
          ls -t | tail -n +6 | xargs -r rm &&
          cd ..
        fi
      "
 
    # Deploy via git pull on server
    - echo "Deploying via git pull..."
    - |
      ssh $SSH_USER@$SSH_HOST "
        cd $DEPLOY_PATH &&
        git fetch origin &&
        git reset --hard origin/main &&
        echo 'Code updated from git'
      "
 
    # Install dependencies, build and reload PM2
    - |
      ssh $SSH_USER@$SSH_HOST "
        cd $DEPLOY_PATH &&
        echo 'Installing dependencies...' &&
        npm ci --prefer-offline &&
 
        echo 'Building application...' &&
        npm run build &&
 
        echo 'Reloading PM2...' &&
        pm2 reload ecosystem.config.cjs --update-env &&
 
        echo 'Waiting for app to start...' &&
        sleep 5 &&
 
        pm2 status
      "
 
    - echo "Deployment completed successfully!"
 
  environment:
    name: production
    url: https://potapov.me
 
  only:
    - main
 
# === STAGE: Health Check ===
healthcheck_production:
  stage: healthcheck
  image: alpine:latest
  needs: ["deploy_production"]
  before_script:
    - apk add --no-cache curl
 
  script:
    - echo "Checking application health..."
    - |
      for i in {1..10}; do
        if curl -f -s https://potapov.me/api/health > /dev/null; then
          echo "✅ Health check passed!"
          exit 0
        else
          echo "⏳ Waiting for app to be ready (attempt $i/10)..."
          sleep 5
        fi
      done
      echo "❌ Health check failed after 10 attempts"
      exit 1
 
  dependencies:
    - deploy_production
 
  only:
    - main
 
# === STAGE: Rollback (manual) ===
rollback_production:
  stage: rollback
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
 
  script:
    - echo "Rolling back to previous version..."
    - |
      ssh $SSH_USER@$SSH_HOST "
        cd $DEPLOY_PATH &&
 
        # Find latest backup
        LATEST_BACKUP=\$(ls -t backups/*.tar.gz | head -1) &&
 
        if [ -z \"\$LATEST_BACKUP\" ]; then
          echo '❌ No backups found!'
          exit 1
        fi &&
 
        echo 'Restoring from backup: '\$LATEST_BACKUP &&
 
        # Restore from backup
        tar -xzf \$LATEST_BACKUP &&
 
        # Reload PM2
        pm2 reload ecosystem.config.cjs &&
 
        echo '✅ Rollback completed!'
      "
 
  when: manual
  only:
    - main

What this pipeline does:

  1. Install: installs dependencies with caching for faster subsequent runs
  2. Lint: checks code for style errors
  3. Build: builds Next.js application and saves artifacts
  4. Deploy: connects via SSH, creates backup, pulls code via git, installs dependencies, builds, restarts PM2
  5. Health Check: verifies application availability via /api/health
  6. Rollback: manual stage to rollback to previous version from backup

Alternatives and Extensions

The GitLab CI + PM2 + NPM stack described in the article is not the only option. Here are several alternatives for different scenarios:

Nginx Proxy Manager Alternatives

Traefik

Traefik: modern reverse proxy with automatic configuration via Docker labels.

Advantages:

  • Automatic service discovery via Docker/Kubernetes
  • Built-in Let's Encrypt
  • Support for HTTP/2, HTTP/3, gRPC
  • Powerful middleware system (rate limiting, authentication, etc.)

When to use: If you have microservices architecture with Docker/Kubernetes.

Caddy

Caddy: minimalist web server with automatic HTTPS.

Advantages:

  • Automatic HTTPS out of the box (Let's Encrypt)
  • Simplest configuration (Caddyfile)
  • Built-in reverse proxy

Example configuration:

potapov.me {
    reverse_proxy localhost:3000
}

When to use: If you want the simplest setup without web interface.

All-in-One Solutions (Vercel Alternatives)

If you don't want to configure CI/CD manually, there are open-source "out of the box" platforms:

Coolify

Coolify: self-hosted alternative to Vercel/Netlify/Heroku.

What it can do:

  • Git push to deploy (supports GitHub, GitLab, Bitbucket)
  • Automatic SSL certificates
  • Database management (PostgreSQL, MySQL, Redis, MongoDB)
  • Automatic backups
  • Built-in monitoring
  • Web UI for management

Pros:

  • 5-minute installation
  • Supports Next.js, Node.js, PHP, Python, Ruby, Go, Rust
  • Free and open-source

Cons:

  • Requires more resources (Docker overhead)
  • Less control over infrastructure

Installation:

curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash

From personal experience: I tested Coolify six months ago (spring 2025). The concept is cool, UI is convenient, but the platform turned out to be very buggy and raw:

  • Deployments periodically failed without clear errors
  • Database crashed several times after updates
  • Had to manually restart Docker containers

Maybe it's better now (active development), but I wouldn't risk it for critical projects yet. For experiments and non-critical services: you can try it.

CapRover

CapRover: PaaS platform for one-command application deployment.

Features:

  • One-click apps (WordPress, Ghost, Grafana, etc.)
  • Multi-server deployment
  • Load balancing
  • Automatic HTTPS

When to use: If you need to quickly deploy multiple applications on one server.

Dokploy

Dokploy: new open-source Vercel alternative (appeared in 2024).

What's interesting:

  • Modern UI (similar to Vercel)
  • Monorepo support
  • Preview deployments for pull requests
  • Edge functions
  • Built-in analytics

Status: Active development, but can already be used in production.

GitHub: dokploy/dokploy

Selection Recommendations

ScenarioRecommendation
Hobby project, 1-2 applicationsBasic setup (GitLab CI + PM2 + NPM/Caddy)
Many applications on one serverNginx Proxy Manager or Traefik
Want "like Vercel" but self-hostedCoolify or Dokploy
Microservices with DockerTraefik + Kubernetes/Docker Swarm
Maximum simplicityCapRover
Full control and flexibilityManual setup (this article)

My choice for potapov.me: Basic setup without frills: GitLab CI, git pull, PM2, NPM. Works stably, doesn't require maintenance, enough for a hobby project.

For client projects I use Coolify: convenient to manage multiple applications through a single interface, clients can deploy themselves via Git push.

Troubleshooting

Common Problems and Solutions

1. PM2 Doesn't Start After Server Reboot

Symptoms: After reboot application doesn't work, pm2 status shows empty list.

Solution:

# Check PM2 systemd service status
systemctl status pm2-$USER
 
# If service not active, configure autostart
pm2 startup systemd -u $USER --hp $HOME
# Execute the sudo command that PM2 outputs
 
# Save current process list
pm2 save
 
# Test
sudo reboot
# After reboot check
pm2 status

2. GitLab CI Pipeline Fails with "Permission denied" on SSH

Symptoms:

Permission denied (publickey).
fatal: Could not read from remote repository.

Solution:

# 1. Check that SSH key is added to GitLab Variables
# Settings → CI/CD → Variables → SSH_PRIVATE_KEY
 
# 2. Make sure public key is added on server
cat ~/.ssh/authorized_keys
 
# 3. Check .ssh directory permissions
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
 
# 4. Check SSH connection manually
ssh -i ~/.ssh/gitlab_ci_key user@server
 
# 5. Add -vvv for diagnostics
ssh -vvv user@server

3. Health Check Fails After Deployment

Symptoms: Pipeline fails on healthcheck stage.

Diagnostics:

# 1. Check that application is running
pm2 status
pm2 logs app-name --lines 50
 
# 2. Check if process is listening on the right port
sudo netstat -tlnp | grep 3000
# or
sudo lsof -i :3000
 
# 3. Check health endpoint locally
curl http://localhost:3000/api/health
 
# 4. Check from external IP
curl http://YOUR_IP:3000/api/health
 
# 5. Check Nginx/NPM logs
docker logs nginx-proxy-manager_app_1

Common causes:

  • Application crashed on startup (check pm2 logs)
  • Wrong port in ecosystem.config.cjs
  • Health endpoint doesn't respond (check API route code)
  • Firewall blocks port (check ufw status)

4. Build Fails: "FATAL ERROR: Reached heap limit"

Symptoms: Next.js build crashes with memory error.

Solution:

# 1. Check available memory
free -h
 
# 2. Create swap file (if none)
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
 
# 3. Make swap permanent
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
 
# 4. Or increase heap for Node.js in package.json
"scripts": {
  "build": "NODE_OPTIONS='--max-old-space-size=2048' next build"
}

5. git pull Fails with Conflicts or Very Slow Deployment

Symptoms: Git complains about local changes or deployment takes > 10 minutes.

Solution:

# Option 1: Hard reset (removes all local changes)
ssh user@server "
  cd /var/www/app &&
  git fetch origin &&
  git reset --hard origin/main &&
  git clean -fd
"
 
# Option 2: Stash local changes
ssh user@server "
  cd /var/www/app &&
  git stash &&
  git pull origin main
"
 
# Option 3: If repository is large, use shallow clone
# During initial setup:
git clone --depth 1 https://gitlab.com/user/repo.git /var/www/app
 
# During deployment:
ssh user@server "
  cd /var/www/app &&
  git fetch --depth 1 &&
  git reset --hard origin/main
"

Useful Diagnostic Commands

# === PM2 ===
pm2 status                      # All processes status
pm2 logs app-name --lines 100   # Application logs
pm2 monit                       # Real-time monitoring
pm2 describe app-name           # Detailed information
pm2 reset app-name              # Reset restart counters
 
# === Port checking ===
sudo netstat -tlnp              # All listening ports
sudo lsof -i :3000              # Process on port 3000
nc -zv localhost 3000           # Check port availability
 
# === System logs ===
journalctl -u pm2-$USER -f      # PM2 systemd service logs
journalctl -u nginx -f          # Nginx logs (if not Docker)
tail -f /var/log/syslog         # System logs
 
# === Docker (for NPM) ===
docker ps                       # Running containers
docker logs <container> -f      # Container logs
docker exec -it <container> sh  # Enter container
 
# === Disk space ===
df -h                           # Free disk space
du -sh /var/www/*               # Directory sizes
du -sh node_modules .next       # Artifact sizes
 
# === Memory ===
free -h                         # Memory and swap
htop                            # Interactive monitoring

What stands

potapov.me runs this way. One public IP, PM2, Nginx Proxy Manager, GitLab CI. Reload without a hole, secrets in Variables, rollback by hand and on a health check.

Later, if needed: Docker, migrations in the pipeline, Playwright, a bundle budget, a CDN.

GitLab CI, PM2, Next.js deploy, Nginx Proxy Manager.