006 — VPS Setup Guide

🖥️ A VPS (Virtual Private Server) is a remote Linux machine in the cloud where we deploy our application. We'll use DigitalOcean to create a "Droplet" (their name for a VPS) and configure it for Docker deployment.


Table of Contents

  1. Creating a Droplet
  2. First SSH Login
  3. Initial Server Security
  4. Installing Docker
  5. UFW Firewall Configuration
  6. Directory Structure for Deployment
  7. Creating a Managed Database
  8. Verifying Everything Works
  9. Useful Server Commands
  10. Quick Reference Card

1. Creating a Droplet

1.1 Navigate to Droplet Creation

  1. Log in to https://cloud.digitalocean.com
  2. Click Create (green button, top-right) → Droplets

1.2 Choose Configuration

SettingRecommended Choice
RegionChoose closest to you (e.g., Singapore, Frankfurt)
ImageUbuntu 24.04 (LTS) x64
Droplet TypeBasic (Shared CPU)
CPU OptionsRegular (SSD)
Size$6/mo — 1 GB RAM / 1 CPU / 25 GB SSD / 1000 GB Transfer
AuthenticationSSH Key (select the key you added earlier)
HostnameSomething descriptive: kanban-prod or bootcamp-yourname

💡 Why $6/mo (1GB RAM)?

  • Sufficient for running 4-5 Docker containers (Caddy + API + Web + Admin + Watchtower)
  • We will add swap space to prevent out-of-memory issues
  • If you still experience memory issues, you can resize later
  • For production with more traffic, consider $12/mo (2GB RAM)

1.3 Select Authentication Method

Option A: SSH Key (Recommended ✅)

  1. Click New SSH Key
  2. On your local machine, copy your public key:
    cat ~/.ssh/id_ed25519.pub
    
  3. Paste it into the form
  4. Name it (e.g., My Laptop)
  5. Click Add SSH Key

Option B: Password (Fallback)

If SSH key setup failed, select "Password" and create a strong root password. You'll change this later.

1.4 Additional Options (Optional)

  • ✅ Monitoring — Free, adds CPU/RAM/disk graphs to dashboard
  • ☐ IPv6 — Not needed for bootcamp
  • ☐ User data — Not needed

1.5 Create the Droplet

  1. Click Create Droplet
  2. Wait 30-60 seconds for creation
  3. Copy the IP address that appears (e.g., 167.71.123.45)

📌 Write down your Droplet IP! You'll use it throughout the bootcamp. Send it to the instructor for DNS configuration.

1.6 One-Click Docker Alternative

DigitalOcean offers a Docker pre-installed image:

  1. During droplet creation, go to Marketplace tab
  2. Search for Docker
  3. Select Docker on Ubuntu 24.04

This skips Section 4 (Installing Docker) entirely.


2. First SSH Login

2.1 Connect to Your Droplet

From your local terminal (WSL Ubuntu or macOS Terminal):

ssh root@YOUR_DROPLET_IP

Replace YOUR_DROPLET_IP with the IP from step 1.5.

First-time connection prompt:

The authenticity of host '167.71.123.45' can't be established.
ED25519 key fingerprint is SHA256:abc123def456...
Are you sure you want to continue connecting (yes/no/[fingerprint])? 

Type yes and press Enter.

2.2 Verify You're Connected

You should see something like:

Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.x.x-x-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

root@kanban-prod:~#

You're now on the remote server! 🎉

2.3 Add SSH Config Entry (Local Machine)

Back on your local machine, add a shortcut:

# Add to ~/.ssh/config
cat >> ~/.ssh/config << 'EOF'

Host vps
    HostName YOUR_DROPLET_IP
    User root
    IdentityFile ~/.ssh/id_ed25519
    ServerAliveInterval 60
    ServerAliveCountMax 3
EOF

Replace YOUR_DROPLET_IP with your actual IP.

Now you can connect with just:

ssh vps

2.4 If SSH Fails

ErrorCauseFix
Connection refusedDroplet not ready yetWait 1 minute, try again
Connection timed outWrong IP or firewallVerify IP on DO dashboard
Permission deniedKey mismatchSee SSH Cheatsheet, Section 6
Host key verification failedIP was reusedssh-keygen -R YOUR_IP

3. Initial Server Security

Run these commands on the VPS (after SSH-ing in).

3.1 Update System Packages

apt update && apt upgrade -y

3.2 Set Timezone

timedatectl set-timezone Asia/Kuala_Lumpur

Verify:

timedatectl
# Should show your timezone

💡 Find your timezone: timedatectl list-timezones | grep Asia

3.3 Install Essential Tools

apt install -y \
  curl \
  wget \
  git \
  nano \
  htop \
  ncdu \
  unzip \
  net-tools \
  dnsutils \
  ca-certificates \
  gnupg \
  lsb-release
ToolPurpose
curl / wgetDownload files
gitVersion control (if needed on server)
nanoSimple text editor
htopSystem resource monitor (better than top)
ncduDisk usage analyzer
net-toolsNetwork utilities (netstat, etc.)
dnsutilsDNS tools (dig, nslookup)

3.4 Add Swap Space (Required)

⚠️ This step is required for the $6/mo droplet (1GB RAM). Running 5 Docker containers without swap will cause out-of-memory (OOM) kills and container restart loops.

# Create 1GB swap file
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

# Make swap persistent across reboots
echo '/swapfile none swap sw 0 0' >> /etc/fstab

# Verify swap is active
free -h

Expected output (Swap row should show ~1.0Gi):

               total        used        free      shared  buff/cache   available
Mem:           981Mi       120Mi       650Mi       1.0Mi       210Mi       720Mi
Swap:          1.0Gi          0B       1.0Gi

💡 Why swap? When RAM is full, Linux kills processes (OOM Killer). Swap provides overflow space on disk — slower than RAM, but prevents crashes. For our 5 containers on 1GB RAM, swap is essential.

3.5 (Optional) Create a Non-Root User

For production servers, running as root is discouraged. For a bootcamp, root is fine.

If you want to set it up properly:

# Create user
adduser deploy

# Give sudo access
usermod -aG sudo deploy

# Copy SSH key to new user
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

# Test login (from local machine)
ssh deploy@YOUR_DROPLET_IP

4. Installing Docker

🐳 Skip this section if you used the DigitalOcean Docker Marketplace image.

4.1 Install Docker Engine

Run these commands on the VPS:

# Remove any old Docker installations
apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null

# Add Docker's official GPG key
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg

# Add Docker repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker Engine + Compose Plugin
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

4.2 Verify Docker Installation

# Check Docker version
docker --version
# Docker version 27.x.x, build xxxxx

# Check Docker Compose version
docker compose version
# Docker Compose version v2.x.x

# Run test container
docker run --rm hello-world

You should see "Hello from Docker!" 🎉

4.3 Enable Docker to Start on Boot

systemctl enable docker
systemctl enable containerd

4.4 Verify Docker is Running

systemctl status docker

Should show active (running).


5. UFW Firewall Configuration

🔥 UFW (Uncomplicated Firewall) controls which ports are accessible from the internet. We only want to expose SSH (22), HTTP (80), and HTTPS (443).

5.1 Check Current Status

ufw status
# Status: inactive (initially)

5.2 Set Default Policies

# Deny all incoming traffic by default
ufw default deny incoming

# Allow all outgoing traffic
ufw default allow outgoing

5.3 Allow Required Ports

# SSH (CRITICAL — don't lock yourself out!)
ufw allow 22/tcp comment "SSH"

# HTTP (for Caddy / Let's Encrypt)
ufw allow 80/tcp comment "HTTP"

# HTTPS (for production traffic)
ufw allow 443/tcp comment "HTTPS"

5.4 Enable the Firewall

ufw enable

You'll see:

Command may disrupt existing SSH connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup

⚠️ Make sure you allowed port 22 BEFORE enabling! Otherwise you'll lock yourself out.

5.5 Verify Firewall Rules

ufw status verbose

Expected output:

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere       # SSH
80/tcp                     ALLOW IN    Anywhere       # HTTP
443/tcp                    ALLOW IN    Anywhere       # HTTPS
22/tcp (v6)                ALLOW IN    Anywhere (v6)  # SSH
80/tcp (v6)                ALLOW IN    Anywhere (v6)  # HTTP
443/tcp (v6)               ALLOW IN    Anywhere (v6)  # HTTPS

5.6 UFW Quick Commands

CommandDescription
ufw statusShow current rules
ufw status numberedShow rules with numbers
ufw allow 8080/tcpOpen a port
ufw deny 8080/tcpBlock a port
ufw delete 3Delete rule #3 (use status numbered first)
ufw delete allow 8080/tcpDelete by rule
ufw disableTurn off firewall
ufw resetRemove all rules
ufw reloadReload rules

5.7 Important Note About Docker & UFW

⚠️ Docker bypasses UFW by default!

When you use -p 8080:80 in Docker, it opens port 8080 directly via iptables, completely bypassing UFW rules.

For our bootcamp setup, this is fine because:

  • Only Caddy (gateway) exposes ports 80/443
  • Other containers (api, web, admin) don't publish ports to the host
  • They communicate through Docker's internal network

If you want to fix this for production, add to /etc/docker/daemon.json:

{
  "iptables": false
}

Then restart Docker: systemctl restart docker

But for the bootcamp, don't do this — leave the default behavior.


6. Directory Structure for Deployment

6.1 Create Project Directory

mkdir -p ~/kanban-prod
cd ~/kanban-prod

6.2 Create Required Files

We need three files in this directory:

~/kanban-prod/
├── .env                  ← Secrets & configuration
├── Caddyfile             ← Reverse proxy configuration
└── docker-compose.yml    ← Container orchestration

6.3 Create the .env File

nano ~/kanban-prod/.env

Paste the following (replace placeholder values):

# ─── Database (DigitalOcean Managed PostgreSQL) ─────────
DATABASE_URL=Host=YOUR_DB_HOST;Port=25060;Database=defaultdb;Username=doadmin;Password=YOUR_DB_PASSWORD;SSL Mode=Require;Trust Server Certificate=true

# ─── Admin Seed ─────────────────────────────────────────
ADMIN_EMAIL=admin@kanban.local
ADMIN_PASSWORD=Admin123!
ADMIN_DISPLAY_NAME=System Admin

# ─── ASP.NET ────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production

# ─── CORS & Cookies ─────────────────────────────────────
CORS_ORIGINS=https://USERNAME.lazuar.dev
COOKIE_DOMAIN=USERNAME.lazuar.dev

# ─── Watchtower (GHCR credentials for auto-updates) ─────
REPO_USER=USERNAME
REPO_PASS=ghp_YOUR_PERSONAL_ACCESS_TOKEN

Save: Ctrl+X, then Y, then Enter.

⚠️ Replace:

  • USERNAME → your GitHub username (in CORS_ORIGINS, COOKIE_DOMAIN, and REPO_USER)
  • YOUR_DB_HOST / YOUR_DB_PASSWORD → your actual database credentials (see 008 - Managed Database)
  • ghp_YOUR_PERSONAL_ACCESS_TOKEN → your GitHub PAT with write:packages scope

6.4 Create the Caddyfile

nano ~/kanban-prod/Caddyfile

Paste (replace USERNAME with your GitHub username):

USERNAME.lazuar.dev {
    # API Traffic → .NET API container
    handle /api/* {
        reverse_proxy api:5010
    }

    # SignalR Hub (WebSocket support)
    handle /api/hubs/* {
        reverse_proxy api:5010
    }

    # Admin Panel → Admin Caddy container
    handle /admin/* {
        reverse_proxy admin:80
    }

    # Everything else → Next.js Web App
    handle /* {
        reverse_proxy web:3000
    }
}

Save: Ctrl+X, then Y, then Enter.

⚠️ Replace USERNAME with your actual GitHub username. Example: alidev.lazuar.dev { ... }


6.5 Create the docker-compose.yml

nano ~/kanban-prod/docker-compose.yml

Paste the following (replace USERNAME with your GitHub username in the three image paths):

services:
  # ─── GATEWAY (Caddy — HTTPS + Reverse Proxy) ───────────
  gateway:
    image: caddy:2-alpine
    container_name: kanban-gateway
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - api
      - web
      - admin

  # ─── API (.NET) ────────────────────────────────────────
  api:
    image: ghcr.io/USERNAME/bootcamp/api:latest
    container_name: kanban-api
    restart: always
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT}
      - ADMIN_EMAIL=${ADMIN_EMAIL}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
      - ADMIN_DISPLAY_NAME=${ADMIN_DISPLAY_NAME}
      - CORS_ORIGINS=${CORS_ORIGINS}
      - COOKIE_DOMAIN=${COOKIE_DOMAIN}

  # ─── WEB (Next.js) ────────────────────────────────────
  web:
    image: ghcr.io/USERNAME/bootcamp/web:latest
    container_name: kanban-web
    restart: always

  # ─── ADMIN (Vite + Caddy) ─────────────────────────────
  admin:
    image: ghcr.io/USERNAME/bootcamp/admin:latest
    container_name: kanban-admin
    restart: always

  # ─── WATCHTOWER (Auto-Update from GHCR) ───────────────
  watchtower:
    image: containrrr/watchtower
    container_name: kanban-watchtower
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - REPO_USER=${REPO_USER}
      - REPO_PASS=${REPO_PASS}
    command: --interval 300 --cleanup

volumes:
  caddy_data:
  caddy_config:

Save: Ctrl+X, then Y, then Enter.

⚠️ Replace USERNAME in ALL THREE image paths with your actual GitHub username (lowercase). Example: ghcr.io/alidev/bootcamp/api:latest

📌 GitHub converts usernames to lowercase in package paths. If your username is AliDev, use alidev in the image paths.


6.6 Login to GitHub Container Registry

Before Docker can pull images, authenticate with GHCR:

export GH_USERNAME="YOUR_GITHUB_USERNAME"
export CR_PAT="ghp_YOUR_TOKEN"

echo $CR_PAT | docker login ghcr.io -u $GH_USERNAME --password-stdin

Expected output:

Login Succeeded

📌 Your PAT needs write:packages scope (which includes read access). This is the same token you configured for GitHub Actions secrets and Watchtower.

💡 This login persists in ~/.docker/config.json. You only need to do it once unless the token expires or you regenerate it.

Verify Login

# Check that credentials are stored
cat ~/.docker/config.json | grep ghcr
# Should show: "ghcr.io": { ... }

# Test by pulling an image
docker pull ghcr.io/USERNAME/bootcamp/api:latest

Replace USERNAME with your actual GitHub username (lowercase).

If Login Fails

ErrorCauseFix
unauthorized: unauthenticatedWrong username or tokenDouble-check both values
denied: requested access to the resource is deniedToken lacks write:packages scopeRegenerate PAT with correct scope
Error response from daemon: Get "https://ghcr.io/v2/": net/http: request canceledNetwork issue on VPSCheck ping ghcr.io and retry

6.7 Final Directory Verification

ls -la ~/kanban-prod/

Should show:

total 16
drwxr-xr-x 2 root root 4096 ... .
drwx------ 5 root root 4096 ... ..
-rw-r--r-- 1 root root  450 ... .env
-rw-r--r-- 1 root root  320 ... Caddyfile
-rw-r--r-- 1 root root  980 ... docker-compose.yml

6.8 Pre-Flight Check: Verify Images Are Available

Before running docker compose up, confirm the images exist and are pullable:

cd ~/kanban-prod

# Pull all images defined in docker-compose.yml
docker compose pull

If this succeeds: All images downloaded. You're ready to launch.

If this fails with "not found" or "unauthorized":

Likely CauseFix
First GitHub Actions build hasn't completedCheck github.com/USERNAME/bootcamp/actions — wait for green ✅
Build failedCheck Actions logs for errors, fix and re-push
GHCR login expired or wrong tokenRe-run docker login ghcr.io (Section 6.6)
Packages not linked to repositoryComplete package linking — see 001, Section 2.7
Username case mismatchUse lowercase in image paths (e.g., alidev not AliDev)

📌 Remember: Your VPS cannot pull images until GitHub Actions has successfully built and pushed them. If you're stuck here, verify at https://github.com/USERNAME?tab=packages that all three packages exist with a recent "Published" timestamp.


7. Creating a Managed Database

🗄️ We use DigitalOcean Managed PostgreSQL so we don't have to manage database backups, updates, or failover ourselves.

7.1 Create the Database Cluster

  1. Go to: https://cloud.digitalocean.com/databases
  2. Click Create Database Cluster
  3. Configure:
SettingValue
EnginePostgreSQL 17
RegionSame as your Droplet
PlanBasic — $15/mo (1 GB RAM, 1 vCPU, 10 GB Storage)
Cluster namekanban-db
  1. Click Create Database Cluster
  2. Wait 3-5 minutes for provisioning

7.2 Secure the Database

After creation, go to the database Settings tab:

  1. Trusted Sources: Click Edit
  2. Add your Droplet (select it from the list or enter the IP)
  3. Click Save

⚠️ This is critical! Without adding your Droplet as a trusted source, the API container cannot reach the database.

7.3 Get the Connection String

  1. Go to your database cluster's Overview tab
  2. In the Connection Details section:
    • Select Connection string dropdown
    • Select Format: Connection parameters

You'll see:

host     = db-postgresql-sgp1-12345-do-user-xxxxx-0.c.db.ondigitalocean.com
port     = 25060
username = doadmin
password = AVNS_xxxxxxxxxxxxx
database = defaultdb
sslmode  = require

7.4 Format for .NET Connection String

Convert the above into the format our API expects:

Host=db-postgresql-sgp1-12345-do-user-xxxxx-0.c.db.ondigitalocean.com;Port=25060;Database=defaultdb;Username=doadmin;Password=AVNS_xxxxxxxxxxxxx;SSL Mode=Require;Trust Server Certificate=true

7.5 Update Your .env File

SSH into your VPS and update the .env:

ssh vps
nano ~/kanban-prod/.env

Replace the DATABASE_URL line with your actual connection string.

7.6 Test Database Connectivity (From VPS)

# Install PostgreSQL client
apt install -y postgresql-client

# Test connection
psql "host=YOUR_DB_HOST port=25060 dbname=defaultdb user=doadmin password=YOUR_PASSWORD sslmode=require"

If you see the defaultdb=> prompt, the connection works! Type \q to exit.

7.7 Alternative: Use Containerized PostgreSQL

If you want to skip the managed database (saves $15/month):

Replace the docker-compose.yml to include PostgreSQL:

services:
  # ─── DATABASE ──────────────────────────────────────────
  postgres:
    image: postgres:17
    container_name: kanban-postgres
    restart: always
    environment:
      POSTGRES_USER: kanban
      POSTGRES_PASSWORD: kanban_secure_password_here
      POSTGRES_DB: kanban
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U kanban"]
      interval: 5s
      timeout: 5s
      retries: 5

  # ... (rest of services stay the same, but api depends_on postgres)
  api:
    # ... existing config ...
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
  caddy_data:
  caddy_config:

And update .env:

DATABASE_URL=Host=postgres;Port=5432;Database=kanban;Username=kanban;Password=kanban_secure_password_here

⚠️ With containerized DB: If you run docker compose down -v, you lose all data. Managed databases don't have this risk.


8. Verifying Everything Works

8.1 Launch the Stack

cd ~/kanban-prod
docker compose up -d

8.2 Check Container Status

docker compose ps

Expected output (all should be "Up"):

NAME                IMAGE                                        STATUS
kanban-gateway      caddy:2-alpine                               Up      0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
kanban-api          ghcr.io/USERNAME/bootcamp/api:latest          Up
kanban-web          ghcr.io/USERNAME/bootcamp/web:latest          Up
kanban-admin        ghcr.io/USERNAME/bootcamp/admin:latest        Up
kanban-watchtower   containrrr/watchtower                        Up

8.3 Check Logs for Errors

# All services
docker compose logs --tail 20

# Specific service (check API for database connection)
docker compose logs --tail 30 api

# Check Caddy for SSL certificate
docker compose logs gateway | grep -i "certificate"

Look for these success indicators:

API logs:

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:5010
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand successfully

Gateway (Caddy) logs:

"msg":"certificate obtained successfully"
"msg":"enabling automatic TLS"

8.4 Test from Your Browser

Visit: https://USERNAME.lazuar.dev

You should see the Kanban application! 🎉

URLWhat you should see
https://USERNAME.lazuar.devWeb app (login page)
https://USERNAME.lazuar.dev/admin/Admin panel
https://USERNAME.lazuar.dev/api/"Kanban API is running!"

8.5 Test from Command Line (On VPS)

# Test API health
curl -s http://localhost:80/api/health
# Should return: Healthy

# Test with HTTPS (from anywhere)
curl -s https://USERNAME.lazuar.dev/api/
# Should return: Kanban API is running!

8.6 If Something Is Wrong

# Full diagnostic
echo "=== CONTAINER STATUS ==="
docker compose ps

echo ""
echo "=== API LOGS (last 20) ==="
docker compose logs --tail 20 api

echo ""
echo "=== GATEWAY LOGS (last 10) ==="
docker compose logs --tail 10 gateway

echo ""
echo "=== DISK SPACE ==="
df -h /

echo ""
echo "=== MEMORY ==="
free -h

echo ""
echo "=== PORTS IN USE ==="
ss -tlnp | grep -E ':(80|443|5010|3000)\s'

Common fixes:

# Container won't start — check logs
docker compose logs api

# Port already in use
docker compose down && docker compose up -d

# Out of memory
docker system prune -af
docker compose up -d

# SSL not working — ensure DNS is pointing to this IP
dig USERNAME.lazuar.dev

# Rebuild everything from scratch
docker compose down -v
docker compose pull
docker compose up -d

9. Useful Server Commands

9.1 System Monitoring

CommandDescription
htopInteractive process viewer (press q to exit)
free -hMemory usage
df -hDisk space
du -sh /var/lib/dockerDocker disk usage
ncdu /Interactive disk usage explorer
uptimeServer uptime and load average
wWho is logged in
lastLogin history

9.2 Log Management

CommandDescription
journalctl -u docker --since "1 hour ago"Docker daemon logs
journalctl -fFollow system logs
tail -f /var/log/syslogSystem log
tail -f /var/log/auth.logAuthentication log (SSH attempts)

9.3 Network Diagnostics

CommandDescription
curl -I https://USERNAME.lazuar.devCheck HTTP headers
dig USERNAME.lazuar.devDNS lookup
ss -tlnpShow listening ports
netstat -tulpnShow all connections
ping google.comTest internet connectivity
traceroute google.comTrace network path

9.4 File Operations

CommandDescription
nano filenameEdit file (save: Ctrl+X, Y, Enter)
cat filenameDisplay file contents
less filenameScrollable file viewer
head -20 filenameFirst 20 lines
tail -20 filenameLast 20 lines
find / -name "*.log" -size +100MFind large log files

9.5 Service Management

CommandDescription
systemctl status dockerDocker service status
systemctl restart dockerRestart Docker
systemctl status ufwFirewall status
rebootRestart the server
shutdown -h nowPower off (careful!)

9.6 Quick Server Health Check Script

Create this script on your VPS:

cat > ~/check-health.sh << 'EOF'
#!/bin/bash
echo "╔════════════════════════════════════════════╗"
echo "║         SERVER HEALTH CHECK                ║"
echo "╠════════════════════════════════════════════╣"
echo ""

echo "── System ──────────────────────────────────"
printf "  Uptime:     %s\n" "$(uptime -p)"
printf "  Load:       %s\n" "$(cat /proc/loadavg | cut -d' ' -f1-3)"
printf "  Memory:     %s / %s\n" "$(free -h | awk '/Mem:/{print $3}')" "$(free -h | awk '/Mem:/{print $2}')"
printf "  Swap:       %s / %s\n" "$(free -h | awk '/Swap:/{print $3}')" "$(free -h | awk '/Swap:/{print $2}')"
printf "  Disk:       %s / %s (%s used)\n" "$(df -h / | awk 'NR==2{print $3}')" "$(df -h / | awk 'NR==2{print $2}')" "$(df -h / | awk 'NR==2{print $5}')"
echo ""

echo "── Docker ──────────────────────────────────"
printf "  Containers: %s running\n" "$(docker ps -q | wc -l)"
printf "  Images:     %s\n" "$(docker images -q | wc -l)"
printf "  Disk usage: %s\n" "$(docker system df --format '{{.Size}}' | head -1)"
echo ""

echo "── Containers ──────────────────────────────"
docker ps --format "  {{.Names}}\t{{.Status}}" | column -t -s $'\t'
echo ""

echo "── Firewall ────────────────────────────────"
ufw status | grep -E "^(Status|[0-9])" | head -5
echo ""

echo "── Ports ─────────────────────────────────"
ss -tlnp | grep -E ':(80|443|22)\s' | awk '{printf "  %s\n", $4}'
echo ""
echo "╚════════════════════════════════════════════╝"
EOF

chmod +x ~/check-health.sh

Run it anytime:

~/check-health.sh

10. Quick Reference Card

VPS Setup — Complete Checklist

# ─── 1. FIRST LOGIN ────────────────────────────────────
ssh root@YOUR_DROPLET_IP

# ─── 2. UPDATE SYSTEM ──────────────────────────────────
apt update && apt upgrade -y
timedatectl set-timezone Asia/Kuala_Lumpur

# ─── 3. INSTALL ESSENTIALS ─────────────────────────────
apt install -y curl wget git nano htop ncdu net-tools ca-certificates gnupg lsb-release

# ─── 4. ADD SWAP SPACE ─────────────────────────────────
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

# ─── 5. INSTALL DOCKER ─────────────────────────────────
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker

# ─── 6. CONFIGURE FIREWALL ─────────────────────────────
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment "SSH"
ufw allow 80/tcp comment "HTTP"
ufw allow 443/tcp comment "HTTPS"
ufw enable

# ─── 7. CREATE PROJECT DIRECTORY ───────────────────────
mkdir -p ~/kanban-prod
cd ~/kanban-prod

# ─── 8. CREATE FILES (.env, Caddyfile, docker-compose.yml)
# (see sections 6.3, 6.4, 6.5 above)

# ─── 9. LOGIN TO GHCR ─────────────────────────────────
echo "ghp_YOUR_TOKEN" | docker login ghcr.io -u YOUR_USERNAME --password-stdin

# ─── 10. PULL IMAGES (verify before launching) ─────────
docker compose pull

# ─── 11. LAUNCH ───────────────────────────────────────
docker compose up -d

# ─── 12. VERIFY ───────────────────────────────────────
docker compose ps
docker compose logs --tail 20
curl -s http://localhost/api/health

Daily Operations

# SSH into server
ssh vps

# Check status
cd ~/kanban-prod && docker compose ps

# View logs
docker compose logs -f

# Manual update (if watchtower is slow)
docker compose pull && docker compose up -d

# Restart a service
docker compose restart api

# Check resource usage
docker stats --no-stream

# Check disk space
df -h / && docker system df

# Check memory + swap
free -h

Emergency Commands

# Everything crashed — restart all
cd ~/kanban-prod
docker compose down
docker compose up -d

# Out of disk space
docker system prune -af
docker builder prune -af

# Out of memory (OOM)
# Check if swap is active:
free -h
# If Swap shows 0B, re-enable:
swapon /swapfile

# Database connection failing
# Check if DB is reachable:
docker exec kanban-api sh -c "nc -zv DB_HOST 25060"

# SSL not working
# 1. Check DNS:
dig USERNAME.lazuar.dev
# 2. Check Caddy logs:
docker compose logs gateway | grep -i "error\|certificate"
# 3. Force SSL renewal:
docker compose restart gateway

# Completely start over
cd ~/kanban-prod
docker compose down -v
rm -rf ~/kanban-prod
mkdir ~/kanban-prod
# (recreate files and re-deploy)

Diagram: VPS Architecture

┌─────────────────── INTERNET ────────────────────────────────┐
│                                                              │
│  User's Browser → DNS (Cloudflare) → YOUR_DROPLET_IP        │
│                                                              │
└──────────────────────────┬───────────────────────────────────┘
                           │
                      Port 80/443
                           │
┌──────────────────────────▼───────────────────────────────────┐
│                    DROPLET (Ubuntu VPS)                        │
│                                                               │
│  ┌─────── UFW Firewall ─────────────────────────────────────┐│
│  │  ALLOW: 22 (SSH), 80 (HTTP), 443 (HTTPS)                 ││
│  │  DENY: everything else                                    ││
│  └───────────────────────────────────────────────────────────┘│
│                                                               │
│  ┌─────── Docker ────────────────────────────────────────────┐│
│  │                                                            ││
│  │  ┌─────────┐  ┌─────┐  ┌─────┐  ┌───────┐  ┌──────────┐││
│  │  │ Gateway │  │ API │  │ Web │  │ Admin │  │Watchtower│││
│  │  │ (Caddy) │─▶│.NET │  │Next │  │ Vite  │  │(updater) │││
│  │  │ :80/443 │  │:5010│  │:3000│  │  :80  │  │          │││
│  │  └─────────┘  └─────┘  └─────┘  └───────┘  └──────────┘││
│  │                                                            ││
│  └────────────────────────────────────────────────────────────┘│
│                                                               │
│  ~/kanban-prod/                                               │
│  ├── .env                                                     │
│  ├── Caddyfile                                                │
│  └── docker-compose.yml                                       │
│                                                               │
└───────────────────────────────────────────────────────────────┘
                           │
                      Port 25060
                           │
┌──────────────────────────▼───────────────────────────────────┐
│           DIGITALOCEAN MANAGED POSTGRESQL                      │
│           (Automatic backups, updates, SSL)                    │
└───────────────────────────────────────────────────────────────┘

Image Source

┌─────────────────────────────────────────────────────────────┐
│  WHERE DO THE DOCKER IMAGES COME FROM?                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  Your personal GitHub Actions builds and pushes:             │
│                                                              │
│    ghcr.io/USERNAME/bootcamp/api:latest                      │
│    ghcr.io/USERNAME/bootcamp/web:latest                      │
│    ghcr.io/USERNAME/bootcamp/admin:latest                    │
│                                                              │
│  Built by: .github/workflows/deploy.yml                      │
│  Triggered by: git push origin main                          │
│  Auto-pulled by: Watchtower (every 5 minutes)                │
│                                                              │
│  See: https://github.com/USERNAME?tab=packages               │
│                                                              │
└─────────────────────────────────────────────────────────────┘
Built with LogoFlowershow