How to deploy apps to the zedz.co.za VPS via GitHub + Docker.
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>
Push your code to the main branch. A GitHub Actions workflow triggers automatically.
The workflow uses appleboy/ssh-action to SSH into the VPS as the deploy user, using a deploy key stored as a GitHub secret.
On the VPS: git pull, then docker compose build to create a fresh image with your latest code.
docker compose up -d starts the new container. Caddy automatically routes your subdomain to it via the shared Docker network.
docker image prune -f removes old images to save disk space.
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)
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)
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"]
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
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
# 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
# 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
| Host Port | Service | Subdomain |
|---|---|---|
| 8080 | pantryai-api-prod | pantryai-api.zedz.co.za |
| 8081 | kms (admin hub) | admin.zedz.co.za |
| 8083 | comply-api | comply-api.zedz.co.za |
| 3100 | pantryai-frontend | — |
| 3400 | bothas-frontend | bothas.zedz.co.za |
| 3401 | bothas-api | api.bothas.zedz.co.za |
| 3500 | apk-store | apps.zedz.co.za |
| 3600 | clip | clip.zedz.co.za |
| 3700 | games | games.zedz.co.za |
| 3800 | richloan | loans.zedz.co.za |
| 3900+ | Next available | — |
Always use cat >> to append. If the inode changes (e.g. from editor save), run docker restart pantryai-caddy.
Always use prisma@6. Prisma 7 has breaking changes (removes url from datasource).
Bun is only in Docker. For host operations: docker run --rm -v $(pwd):/app oven/bun:1 bun install
Before first compose up: docker network create pantryai-network 2>/dev/null || true
Newer Bun uses bun.lock (not bun.lockb). Use bun.lock* in Dockerfile COPY.