Skip to main content

Production Blueprint: VPS Deploy with Docker, Caddy, and GitHub Actions

What You Will Set Up

  • Hardened Ubuntu VPS (SSH keys, firewall, fail2ban)
  • Dockerized Next.js app behind Caddy reverse proxy
  • Automatic TLS certificates via Let's Encrypt
  • GitHub Actions deployment on every push to main
  • Deploy traceability with .deploy_sha and .deploy_time_utc

Before You Start

Replace placeholders before running commands:

  • <VPS_IP>
  • app.example.com

Step-by-Step Deployment

1) Harden the VPS

Run on VPS (root):

ssh root@<VPS_IP>
apt-get update
apt-get upgrade -y
apt-get install -y sudo ca-certificates curl gnupg ufw fail2ban unattended-upgrades
adduser deploy
usermod -aG sudo deploy

Run on Laptop (if you do not already have a key):

ssh-keygen -t ed25519 -C "deploy@codereviewai"
ssh-copy-id deploy@<VPS_IP>
ssh deploy@<VPS_IP>

Back on VPS (root), configure SSH hardening:

cat <<'EOF_SSH' > /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowUsers deploy
EOF_SSH

sshd -t
systemctl restart ssh

Enable firewall:

ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status

Enable protection services:

systemctl enable --now fail2ban
dpkg-reconfigure -plow unattended-upgrades
tip

Keep your current root SSH session open until you confirm ssh deploy@<VPS_IP> works in a new terminal.

2) Install Docker Engine and Compose

Run on VPS (deploy user):

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo 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" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"

Reconnect and verify:

exit
ssh deploy@<VPS_IP>
docker version
docker compose version

3) Configure DNS and app environment

Create DNS A record:

  • Host: app (or your preferred subdomain)
  • Value: VPS public IP

Run on VPS (deploy user):

sudo mkdir -p /opt/codereviewai
sudo chown -R "$USER":"$USER" /opt/codereviewai
cd /opt/codereviewai

Generate secrets (repeat as needed):

openssl rand -hex 32

Create .env in /opt/codereviewai/.env:

APP_DOMAIN="app.example.com"
DATABASE_URL="postgresql://user:password@host:5432/dbname?sslmode=require"

BETTER_AUTH_SECRET="<openssl-rand-hex-32>"
BETTER_AUTH_URL="https://app.example.com"
NEXT_PUBLIC_APP_URL="https://app.example.com"

GITHUB_CLIENT_ID="<github-client-id>"
GITHUB_CLIENT_SECRET="<github-client-secret>"
GITHUB_WEBHOOK_SECRET="<openssl-rand-hex-32>"

OPENAI_API_KEY="<openai-api-key>"

INNGEST_EVENT_KEY="<inngest-event-key>"
INNGEST_SIGNING_KEY="<inngest-signing-key>"

4) Configure GitHub Actions secrets

In GitHub -> Settings -> Secrets and variables -> Actions, set:

  • VPS_HOST
  • VPS_PORT
  • VPS_USER (deploy)
  • VPS_SSH_KEY (private key content)
  • VPS_APP_DIR (/opt/codereviewai)
  • DATABASE_URL
  • BETTER_AUTH_SECRET
  • BETTER_AUTH_URL
  • NEXT_PUBLIC_APP_URL
  • GITHUB_CLIENT_ID
  • GITHUB_CLIENT_SECRET
  • OPENAI_API_KEY
  • GITHUB_WEBHOOK_SECRET
  • INNGEST_EVENT_KEY
  • INNGEST_SIGNING_KEY
warning

Never commit production secrets to your repository.

5) Configure integration endpoints

Set exact production URLs:

GitHub OAuth callback: https://app.example.com/api/auth/callback/github
GitHub webhook URL: https://app.example.com/api/webhooks/github
Inngest serve URL: https://app.example.com/api/inngest

6) Trigger first deployment

Run on Laptop:

git add .
git commit -m "Add production VPS deployment stack"
git push origin main

Then confirm the deploy workflow is green in GitHub Actions.

7) Verify production health

Run on Laptop:

curl -I https://app.example.com

Run on VPS (deploy user):

cd /opt/codereviewai
docker compose ps
cat .deploy_sha
cat .deploy_time_utc
docker compose logs --tail=100 app
docker compose logs --tail=100 caddy

Optional: show app container start time:

APP_CID=$(docker compose ps -q app)
docker inspect "$APP_CID" --format 'Started: {{.State.StartedAt}}'

Smoke test:

  1. Open app URL.
  2. Sign in with GitHub.
  3. Connect repository.
  4. Trigger review manually.
  5. Confirm webhook-triggered review on PR update.

Common Failures and Fixes

Workflow file push rejected

Error:

refusing to allow an OAuth App to create or update workflow ... without workflow scope

Fix:

  • Use SSH auth for Git operations
  • Add dedicated GitHub SSH key and alias

CI cannot find pnpm

Fix:

  • Add pnpm/action-setup@v4 before actions/setup-node

Build fails due to missing env vars

Fix:

  • Set required values in GitHub Actions secrets
  • Avoid relying on placeholder env values for production builds

Prisma client missing in CI

Error:

Cannot find module '.prisma/client/default'

Fix:

  • Run pnpm db:generate before pnpm build

Deploy completed but app did not restart

Fix on VPS (deploy user):

cd /opt/codereviewai
docker compose stop app
docker compose rm -f app
docker compose up -d --no-deps app

Day-2 Operations

Check status:

cd /opt/codereviewai
docker compose ps

Tail logs:

docker compose logs -f app
docker compose logs -f caddy

Manual redeploy:

cd /opt/codereviewai
docker compose build --pull app
docker compose run --rm app pnpm db:push
docker compose stop app || true
docker compose rm -f app || true
docker compose up -d --no-deps app
docker compose up -d caddy

Check deployed version:

cd /opt/codereviewai
cat .deploy_sha
cat .deploy_time_utc

Production Checklist

  • SSH password login disabled and key login verified
  • UFW allows only 22, 80, 443
  • Docker and Compose usable by deploy
  • DNS points to VPS IP
  • Caddy issued valid TLS cert
  • GitHub Actions deploy workflow green
  • .deploy_sha matches latest commit
  • Sign-in works on production domain
  • GitHub webhook triggers review
  • Inngest endpoint responds correctly