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_shaand.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
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_HOSTVPS_PORTVPS_USER(deploy)VPS_SSH_KEY(private key content)VPS_APP_DIR(/opt/codereviewai)DATABASE_URLBETTER_AUTH_SECRETBETTER_AUTH_URLNEXT_PUBLIC_APP_URLGITHUB_CLIENT_IDGITHUB_CLIENT_SECRETOPENAI_API_KEYGITHUB_WEBHOOK_SECRETINNGEST_EVENT_KEYINNGEST_SIGNING_KEY
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:
- Open app URL.
- Sign in with GitHub.
- Connect repository.
- Trigger review manually.
- 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@v4beforeactions/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:generatebeforepnpm 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_shamatches latest commit - Sign-in works on production domain
- GitHub webhook triggers review
- Inngest endpoint responds correctly