Deployment Guide

How to deploy apps to the zedz.co.za VPS via GitHub + Docker.

AI Context Reference

Copy and paste this into any AI chat so it understands exactly how to deploy to this VPS.

<vps-deployment-reference>
  <server>
    <host>zedz.co.za VPS</host>
    <user>deploy</user>
    <apps-directory>/home/deploy/apps/</apps-directory>
    <platform>Linux (Debian), Docker, Bun runtime</platform>
  </server>

  <architecture>
    <reverse-proxy>Caddy (auto HTTPS via Let's Encrypt)</reverse-proxy>
    <runtime>Bun (oven/bun Docker images)</runtime>
    <database>PostgreSQL 16 (shared instance, container: pantryai-postgres, port 5432)</database>
    <cache>Redis (shared instance, container: pantryai-redis, port 6379)</cache>
    <network>Docker bridge network "pantryai-network" (external, shared by all services)</network>
    <orchestration>Docker Compose per app</orchestration>
  </architecture>

  <deployment-flow>
    <step n="1">Push code to GitHub (main branch triggers deploy)</step>
    <step n="2">GitHub Actions workflow SSHs into VPS using appleboy/ssh-action@v1</step>
    <step n="3">On VPS: git pull, docker compose build, docker compose up -d</step>
    <step n="4">Caddy auto-routes subdomain to container via pantryai-network</step>
    <step n="5">Clean up: docker image prune -f</step>
  </deployment-flow>

  <github-actions-template>
    <file>.github/workflows/deploy.yml</file>
    <content>
name: Deploy to VPS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script_stop: true
          script: |
            cd /home/deploy/apps/YOUR_APP_NAME
            git pull origin main
            docker compose -f docker-compose.prod.yml build --no-cache
            docker compose -f docker-compose.prod.yml up -d
            docker image prune -f
    </content>
    <secrets-needed>
      <secret name="VPS_HOST">Server IP address</secret>
      <secret name="VPS_USER">deploy</secret>
      <secret name="VPS_SSH_KEY">SSH private key (ed25519) for the deploy user</secret>
    </secrets-needed>
  </github-actions-template>

  <dockerfile-template runtime="bun">
FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]
  </dockerfile-template>

  <docker-compose-template>
    <file>docker-compose.prod.yml</file>
    <content>
services:
  app:
    build: .
    container_name: YOUR_APP_NAME
    restart: unless-stopped
    ports:
      - "HOST_PORT:CONTAINER_PORT"
    env_file:
      - .env.production
    networks:
      - pantryai-network
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "bun", "--eval", "fetch('http://localhost:CONTAINER_PORT/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

networks:
  pantryai-network:
    external: true
    </content>
  </docker-compose-template>

  <caddy-setup>
    <file>/home/deploy/apps/pantryai-bun/Caddyfile</file>
    <important>Use "cat >>" to APPEND to Caddyfile, never overwrite (Docker bind mount breaks on inode change). If inode changes, run: docker restart pantryai-caddy</important>
    <template>
your-subdomain.zedz.co.za {
    reverse_proxy YOUR_CONTAINER_NAME:CONTAINER_PORT

    log {
        output file /var/log/caddy/your-app.log
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
    }
}
    </template>
    <after-edit>docker exec pantryai-caddy caddy reload --config /etc/caddy/Caddyfile</after-edit>
  </caddy-setup>

  <port-allocation>
    <range name="backend-apis">8080-8099</range>
    <range name="app-servers">3000-3099 (internal), mapped to 3100+ on host</range>
    <used>
      <port host="8080">pantryai-api-prod</port>
      <port host="8081">kms (admin hub)</port>
      <port host="8082">pantryai-api-dev</port>
      <port host="8083">comply-api</port>
      <port host="3100">pantryai-frontend-prod</port>
      <port host="3400">bothas-frontend</port>
      <port host="3401">bothas-api</port>
      <port host="3500">apk-store</port>
      <port host="3600">clip</port>
      <port host="3700">games</port>
      <port host="3800">richloan</port>
    </used>
    <next-available>3900+ or 8084+</next-available>
  </port-allocation>

  <database-setup>
    <shared-postgres>
      <container>pantryai-postgres</container>
      <internal-port>5432</internal-port>
      <connection-pattern>postgresql://APP_USER:APP_PASS@pantryai-postgres:5432/APP_DB</connection-pattern>
    </shared-postgres>
    <create-new-db>
docker exec -it pantryai-postgres psql -U pantryai -c "CREATE DATABASE your_db;"
docker exec -it pantryai-postgres psql -U pantryai -c "CREATE USER your_user WITH PASSWORD 'your_pass';"
docker exec -it pantryai-postgres psql -U pantryai -c "GRANT ALL PRIVILEGES ON DATABASE your_db TO your_user;"
docker exec -it pantryai-postgres psql -U pantryai -c "ALTER USER your_user CREATEDB;"
    </create-new-db>
    <note>ALTER USER ... CREATEDB is needed if using Prisma migrate dev (shadow database)</note>
  </database-setup>

  <ssh-setup-for-new-repo>
    <step n="1">Generate deploy key: ssh-keygen -t ed25519 -f ~/.ssh/APPNAME_deploy -C "APPNAME-deploy"</step>
    <step n="2">Add public key to GitHub repo Settings > Deploy Keys (read-only)</step>
    <step n="3">Add to ~/.ssh/config:
Host github.com-APPNAME
  HostName github.com
  User git
  IdentityFile ~/.ssh/APPNAME_deploy
  IdentitiesOnly yes</step>
    <step n="4">Clone using alias: git clone git@github.com-APPNAME:OWNER/REPO.git /home/deploy/apps/APPNAME</step>
    <step n="5">Add GitHub secrets: VPS_HOST, VPS_USER, VPS_SSH_KEY (private key content)</step>
  </ssh-setup-for-new-repo>

  <new-app-checklist>
    <item n="1">Create app directory: /home/deploy/apps/YOUR_APP</item>
    <item n="2">Set up SSH deploy key (see ssh-setup-for-new-repo)</item>
    <item n="3">Clone repo to VPS</item>
    <item n="4">Create Dockerfile and docker-compose.prod.yml</item>
    <item n="5">Create .env.production with secrets</item>
    <item n="6">If using Postgres: create database and user</item>
    <item n="7">Pick a free host port (3900+ or 8084+)</item>
    <item n="8">Build and start: docker compose -f docker-compose.prod.yml up -d --build</item>
    <item n="9">Append Caddy config for subdomain (cat >> Caddyfile)</item>
    <item n="10">Reload Caddy: docker exec pantryai-caddy caddy reload --config /etc/caddy/Caddyfile</item>
    <item n="11">Verify: curl https://your-subdomain.zedz.co.za/health</item>
    <item n="12">Add GitHub Actions workflow for auto-deploy on push</item>
  </new-app-checklist>

  <gotchas>
    <gotcha>Caddyfile: APPEND with "cat >>", never overwrite. Docker bind mount breaks on inode change.</gotcha>
    <gotcha>Prisma: Always use prisma@6 (Prisma 7 has breaking changes, removes url from datasource).</gotcha>
    <gotcha>Bun lockfile: Named "bun.lock" (not bun.lockb) in newer Bun versions.</gotcha>
    <gotcha>No bun on host: Use docker run --rm -v ... oven/bun:1 bun install for host operations.</gotcha>
    <gotcha>New DB users need: ALTER USER ... CREATEDB (for Prisma shadow database).</gotcha>
    <gotcha>Docker network must exist before compose up: docker network create pantryai-network 2>/dev/null || true</gotcha>
    <gotcha>Nginx regex priority: Use "location ^~ /api/" to prevent static file rules from intercepting API routes.</gotcha>
    <gotcha>Elysia redirects: set.redirect doesn't work. Use set.status = 302; set.headers["location"] = url;</gotcha>
  </gotchas>

  <existing-subdomains>
    <subdomain domain="admin.zedz.co.za">KMS / Admin Hub</subdomain>
    <subdomain domain="pantryai-api.zedz.co.za">PantryAI API (prod)</subdomain>
    <subdomain domain="comply-api.zedz.co.za">Comply API</subdomain>
    <subdomain domain="bothas.zedz.co.za">Bothas Frontend</subdomain>
    <subdomain domain="api.bothas.zedz.co.za">Bothas API</subdomain>
    <subdomain domain="apps.zedz.co.za">APK Store</subdomain>
    <subdomain domain="clip.zedz.co.za">Cross-device Clipboard</subdomain>
    <subdomain domain="games.zedz.co.za">Multiplayer Games</subdomain>
    <subdomain domain="loans.zedz.co.za">RichLoan</subdomain>
  </existing-subdomains>
</vps-deployment-reference>

How Deployment Works

1

Push to GitHub

Push your code to the main branch. A GitHub Actions workflow triggers automatically.

2

SSH into VPS

The workflow uses appleboy/ssh-action to SSH into the VPS as the deploy user, using a deploy key stored as a GitHub secret.

3

Pull & Build

On the VPS: git pull, then docker compose build to create a fresh image with your latest code.

4

Start Container

docker compose up -d starts the new container. Caddy automatically routes your subdomain to it via the shared Docker network.

5

Cleanup

docker image prune -f removes old images to save disk space.

Architecture

Internet
  |
  v
[Caddy] (ports 80/443, auto HTTPS)
  |
  +--> admin.zedz.co.za    --> kms:8081
  +--> loans.zedz.co.za    --> richloan-app:3000
  +--> games.zedz.co.za    --> games:3000
  +--> clip.zedz.co.za     --> clip:3000
  +--> apps.zedz.co.za     --> apk-store:3000
  +--> bothas.zedz.co.za   --> bothas-frontend:80
  +--> ...
  |
  All on Docker network: "pantryai-network"
  |
  +--> pantryai-postgres:5432 (shared)
  +--> pantryai-redis:6379 (shared)

GitHub Actions Workflow

Template: .github/workflows/deploy.yml

name: Deploy to VPS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script_stop: true
          script: |
            cd /home/deploy/apps/YOUR_APP_NAME
            git pull origin main
            docker compose -f docker-compose.prod.yml build --no-cache
            docker compose -f docker-compose.prod.yml up -d
            docker image prune -f

GitHub Secrets needed: VPS_HOST (server IP), VPS_USER (deploy), VPS_SSH_KEY (SSH private key)

Dockerfile Template

FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]

Docker Compose Template

docker-compose.prod.yml

services:
  app:
    build: .
    container_name: your-app-name
    restart: unless-stopped
    ports:
      - "HOST_PORT:3000"
    env_file:
      - .env.production
    networks:
      - pantryai-network
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "bun", "--eval",
        "fetch('http://localhost:3000/health')
        .then(r=>r.ok?process.exit(0):process.exit(1))
        .catch(()=>process.exit(1))"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

networks:
  pantryai-network:
    external: true

Adding a Subdomain (Caddy)

Important: Always APPEND to the Caddyfile with cat >>. Never overwrite it — Docker bind mounts break on inode changes.

# Append new subdomain
cat >> /home/deploy/apps/pantryai-bun/Caddyfile << 'EOF'

your-app.zedz.co.za {
    reverse_proxy your-container:3000

    log {
        output file /var/log/caddy/your-app.log
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
    }
}
EOF

# Reload Caddy (no restart needed)
docker exec pantryai-caddy caddy reload \
  --config /etc/caddy/Caddyfile

Database Setup

Create a new database for your app

# Create database and user
docker exec -it pantryai-postgres psql -U pantryai -c \
  "CREATE DATABASE your_db;"

docker exec -it pantryai-postgres psql -U pantryai -c \
  "CREATE USER your_user WITH PASSWORD 'your_pass';"

docker exec -it pantryai-postgres psql -U pantryai -c \
  "GRANT ALL PRIVILEGES ON DATABASE your_db TO your_user;"

# Needed for Prisma migrate dev (shadow database)
docker exec -it pantryai-postgres psql -U pantryai -c \
  "ALTER USER your_user CREATEDB;"

Connection string for .env.production: postgresql://your_user:your_pass@pantryai-postgres:5432/your_db

SSH Deploy Key Setup

# 1. Generate a deploy key
ssh-keygen -t ed25519 -f ~/.ssh/APPNAME_deploy \
  -C "APPNAME-deploy"

# 2. Add public key to GitHub repo
#    Settings > Deploy Keys > Add (read-only)
cat ~/.ssh/APPNAME_deploy.pub

# 3. Add SSH config alias
cat >> ~/.ssh/config << 'EOF'

Host github.com-APPNAME
  HostName github.com
  User git
  IdentityFile ~/.ssh/APPNAME_deploy
  IdentitiesOnly yes
EOF

# 4. Clone using alias
git clone git@github.com-APPNAME:OWNER/REPO.git \
  /home/deploy/apps/APPNAME

# 5. Add GitHub Secrets in repo settings:
#    VPS_HOST     = server IP
#    VPS_USER     = deploy
#    VPS_SSH_KEY  = contents of ~/.ssh/APPNAME_deploy

Port Allocation

Host Port Service Subdomain
8080pantryai-api-prodpantryai-api.zedz.co.za
8081kms (admin hub)admin.zedz.co.za
8083comply-apicomply-api.zedz.co.za
3100pantryai-frontend
3400bothas-frontendbothas.zedz.co.za
3401bothas-apiapi.bothas.zedz.co.za
3500apk-storeapps.zedz.co.za
3600clipclip.zedz.co.za
3700gamesgames.zedz.co.za
3800richloanloans.zedz.co.za
3900+Next available

Common Gotchas

Caddyfile edits break Docker bind mount

Always use cat >> to append. If the inode changes (e.g. from editor save), run docker restart pantryai-caddy.

Prisma version

Always use prisma@6. Prisma 7 has breaking changes (removes url from datasource).

No Bun on the host

Bun is only in Docker. For host operations: docker run --rm -v $(pwd):/app oven/bun:1 bun install

Docker network must exist

Before first compose up: docker network create pantryai-network 2>/dev/null || true

Bun lockfile name

Newer Bun uses bun.lock (not bun.lockb). Use bun.lock* in Dockerfile COPY.