006 — VPS Setup Guide
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
- Creating a Droplet
- First SSH Login
- Initial Server Security
- Installing Docker
- UFW Firewall Configuration
- Directory Structure for Deployment
- Creating a Managed Database
- Verifying Everything Works
- Useful Server Commands
- Quick Reference Card
1. Creating a Droplet
1.1 Navigate to Droplet Creation
- Log in to https://cloud.digitalocean.com
- Click Create (green button, top-right) → Droplets
1.2 Choose Configuration
| Setting | Recommended Choice |
|---|---|
| Region | Choose closest to you (e.g., Singapore, Frankfurt) |
| Image | Ubuntu 24.04 (LTS) x64 |
| Droplet Type | Basic (Shared CPU) |
| CPU Options | Regular (SSD) |
| Size | $6/mo — 1 GB RAM / 1 CPU / 25 GB SSD / 1000 GB Transfer |
| Authentication | SSH Key (select the key you added earlier) |
| Hostname | Something 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 ✅)
- Click New SSH Key
- On your local machine, copy your public key:
cat ~/.ssh/id_ed25519.pub - Paste it into the form
- Name it (e.g.,
My Laptop) - 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
- Click Create Droplet
- Wait 30-60 seconds for creation
- 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:
- During droplet creation, go to Marketplace tab
- Search for Docker
- 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
| Error | Cause | Fix |
|---|---|---|
| Connection refused | Droplet not ready yet | Wait 1 minute, try again |
| Connection timed out | Wrong IP or firewall | Verify IP on DO dashboard |
| Permission denied | Key mismatch | See SSH Cheatsheet, Section 6 |
| Host key verification failed | IP was reused | ssh-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
| Tool | Purpose |
|---|---|
curl / wget | Download files |
git | Version control (if needed on server) |
nano | Simple text editor |
htop | System resource monitor (better than top) |
ncdu | Disk usage analyzer |
net-tools | Network utilities (netstat, etc.) |
dnsutils | DNS 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
| Command | Description |
|---|---|
ufw status | Show current rules |
ufw status numbered | Show rules with numbers |
ufw allow 8080/tcp | Open a port |
ufw deny 8080/tcp | Block a port |
ufw delete 3 | Delete rule #3 (use status numbered first) |
ufw delete allow 8080/tcp | Delete by rule |
ufw disable | Turn off firewall |
ufw reset | Remove all rules |
ufw reload | Reload rules |
5.7 Important Note About Docker & UFW
⚠️ Docker bypasses UFW by default!
When you use
-p 8080:80in 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 withwrite:packagesscope
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
USERNAMEwith 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
USERNAMEin 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, usealidevin 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:packagesscope (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
| Error | Cause | Fix |
|---|---|---|
unauthorized: unauthenticated | Wrong username or token | Double-check both values |
denied: requested access to the resource is denied | Token lacks write:packages scope | Regenerate PAT with correct scope |
Error response from daemon: Get "https://ghcr.io/v2/": net/http: request canceled | Network issue on VPS | Check 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 Cause | Fix |
|---|---|
| First GitHub Actions build hasn't completed | Check github.com/USERNAME/bootcamp/actions — wait for green ✅ |
| Build failed | Check Actions logs for errors, fix and re-push |
| GHCR login expired or wrong token | Re-run docker login ghcr.io (Section 6.6) |
| Packages not linked to repository | Complete package linking — see 001, Section 2.7 |
| Username case mismatch | Use 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=packagesthat 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
- Go to: https://cloud.digitalocean.com/databases
- Click Create Database Cluster
- Configure:
| Setting | Value |
|---|---|
| Engine | PostgreSQL 17 |
| Region | Same as your Droplet |
| Plan | Basic — $15/mo (1 GB RAM, 1 vCPU, 10 GB Storage) |
| Cluster name | kanban-db |
- Click Create Database Cluster
- Wait 3-5 minutes for provisioning
7.2 Secure the Database
After creation, go to the database Settings tab:
- Trusted Sources: Click Edit
- Add your Droplet (select it from the list or enter the IP)
- 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
- Go to your database cluster's Overview tab
- 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! 🎉
| URL | What you should see |
|---|---|
https://USERNAME.lazuar.dev | Web 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
| Command | Description |
|---|---|
htop | Interactive process viewer (press q to exit) |
free -h | Memory usage |
df -h | Disk space |
du -sh /var/lib/docker | Docker disk usage |
ncdu / | Interactive disk usage explorer |
uptime | Server uptime and load average |
w | Who is logged in |
last | Login history |
9.2 Log Management
| Command | Description |
|---|---|
journalctl -u docker --since "1 hour ago" | Docker daemon logs |
journalctl -f | Follow system logs |
tail -f /var/log/syslog | System log |
tail -f /var/log/auth.log | Authentication log (SSH attempts) |
9.3 Network Diagnostics
| Command | Description |
|---|---|
curl -I https://USERNAME.lazuar.dev | Check HTTP headers |
dig USERNAME.lazuar.dev | DNS lookup |
ss -tlnp | Show listening ports |
netstat -tulpn | Show all connections |
ping google.com | Test internet connectivity |
traceroute google.com | Trace network path |
9.4 File Operations
| Command | Description |
|---|---|
nano filename | Edit file (save: Ctrl+X, Y, Enter) |
cat filename | Display file contents |
less filename | Scrollable file viewer |
head -20 filename | First 20 lines |
tail -20 filename | Last 20 lines |
find / -name "*.log" -size +100M | Find large log files |
9.5 Service Management
| Command | Description |
|---|---|
systemctl status docker | Docker service status |
systemctl restart docker | Restart Docker |
systemctl status ufw | Firewall status |
reboot | Restart the server |
shutdown -h now | Power 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 │
│ │
└─────────────────────────────────────────────────────────────┘