This guide walks through deploying the full SupaProxy platform to a single $12/month DigitalOcean droplet. Everything runs in Docker Compose with automatic SSL via Caddy.
Architecture
Internet
│
▼
Caddy (:80/:443, auto SSL)
├── your-domain.com → Dashboard (:4322)
└── api.your-domain.com → Server (:3001)
├── MySQL (:3306, internal)
└── Redis (:6379, internal)
Five services on one machine:
Service Purpose Image Caddy Reverse proxy + auto SSL caddy:2-alpineDashboard Astro SSR frontend Built from supaproxy-dashboard Server Hono API backend Built from supaproxy-server MySQL Persistent storage mysql:8Redis Background job queues redis:7-alpine
Prerequisites
A DigitalOcean account
A domain name
An SSH key on your local machine
Step 1: Create the droplet
Add your SSH key to DigitalOcean
Get your public key:
cat ~/.ssh/id_ed25519.pub
# or
cat ~/.ssh/id_rsa.pub
In the DigitalOcean console: Settings > Security > SSH Keys > Add SSH Key . Paste your key.
Create the droplet
Create > Droplets
Region : pick the closest to your users
Image : Ubuntu 24.04 LTS
Size : Basic > Regular > $12/month (2 GB RAM, 1 vCPU)
Authentication : select your SSH key
Hostname : supaproxy
No managed database needed. MySQL and Redis run inside Docker on the same machine.
Verify SSH access
ssh root@YOUR_DROPLET_IP "echo 'connected'"
Step 2: Set up DNS
Create two A records pointing to your droplet IP:
Type Name Value A @ YOUR_DROPLET_IPA api YOUR_DROPLET_IP
Make sure there are no other A records for @. If your registrar adds default parking IPs, delete them. Caddy’s SSL challenge will fail if the domain resolves to multiple IPs.
Verify:
dig your-domain.com A +short
# Should return only your droplet IP
Step 3: Set up GitHub access on the droplet
Generate a deploy key:
ssh root@YOUR_DROPLET_IP \
"ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N '' -C 'supaproxy-droplet' && cat ~/.ssh/id_ed25519.pub"
Add the output to GitHub: Settings > SSH and GPG keys > New SSH key .
Then add GitHub’s host key:
ssh root@YOUR_DROPLET_IP "ssh-keyscan github.com >> ~/.ssh/known_hosts"
Verify:
ssh root@YOUR_DROPLET_IP "ssh -T git@github.com 2>&1"
# "Hi username! You've successfully authenticated..."
Step 4: Install Docker
ssh root@YOUR_DROPLET_IP "curl -fsSL https://get.docker.com | sh"
This installs Docker Engine and the Compose plugin. Takes about a minute.
git clone git@github.com:NumstackPtyLtd/supaproxy-infra.git
cd supaproxy-infra
# Clone the app repos
git clone git@github.com:NumstackPtyLtd/supaproxy-server.git
git clone git@github.com:NumstackPtyLtd/supaproxy-dashboard.git
Generate secrets
cp .env.example .env
JWT_SECRET = $( openssl rand -hex 32 )
DB_PASSWORD = $( openssl rand -hex 16 )
cat > .env << EOF
DB_PASSWORD= $DB_PASSWORD
DB_NAME=supaproxy
JWT_SECRET= $JWT_SECRET
EOF
Update docker-compose.yml
Edit the domain references in docker-compose.yml to match your domain:
CORS_ORIGINS: https://your-domain.com,https://api.your-domain.com
DASHBOARD_URL: https://your-domain.com
PUBLIC_SUPAPROXY_API_URL (build arg and env): https://api.your-domain.com
Update the Caddyfile too:
your-domain.com {
reverse_proxy dashboard:4322
}
api.your-domain.com {
reverse_proxy server:3001
}
Step 6: Deploy
docker compose up -d --build
First run takes a few minutes (pulling images, installing dependencies, building). Subsequent builds are faster due to Docker layer caching.
Verify
# Check all services
docker compose ps
# Check the API
curl https://api.your-domain.com/health
# Check the dashboard
curl -sI https://your-domain.com
The health endpoint should return:
{ "status" : "ok" , "setup_complete" : false , "workspaces" : 0 }
Visit your domain in a browser and sign up.
Configuration files
docker-compose.yml
docker-compose.yml
Caddyfile
services :
mysql :
image : mysql:8
restart : unless-stopped
environment :
MYSQL_ROOT_PASSWORD : ${DB_PASSWORD}
MYSQL_DATABASE : ${DB_NAME}
volumes :
- mysql_data:/var/lib/mysql
ports :
- "127.0.0.1:3306:3306"
healthcheck :
test : [ "CMD" , "mysqladmin" , "ping" , "-h" , "localhost" ]
interval : 10s
timeout : 5s
retries : 5
redis :
image : redis:7-alpine
restart : unless-stopped
volumes :
- redis_data:/data
ports :
- "127.0.0.1:6379:6379"
healthcheck :
test : [ "CMD" , "redis-cli" , "ping" ]
interval : 10s
timeout : 5s
retries : 5
server :
build :
context : ./supaproxy-server
dockerfile : Dockerfile
restart : unless-stopped
depends_on :
mysql :
condition : service_healthy
redis :
condition : service_healthy
environment :
PORT : "3001"
JWT_SECRET : ${JWT_SECRET}
CORS_ORIGINS : https://your-domain.com,https://api.your-domain.com
DB_HOST : mysql
DB_PORT : "3306"
DB_USER : root
DB_PASSWORD : ${DB_PASSWORD}
DB_NAME : ${DB_NAME}
REDIS_HOST : redis
REDIS_PORT : "6379"
NODE_ENV : production
DASHBOARD_URL : https://your-domain.com
ports :
- "127.0.0.1:3001:3001"
dashboard :
build :
context : ./supaproxy-dashboard
dockerfile : apps/web/Dockerfile
args :
PUBLIC_SUPAPROXY_API_URL : https://api.your-domain.com
restart : unless-stopped
depends_on :
- server
environment :
PUBLIC_SUPAPROXY_API_URL : https://api.your-domain.com
SUPAPROXY_API_URL : http://server:3001
HOST : "0.0.0.0"
PORT : "4322"
ports :
- "127.0.0.1:4322:4322"
caddy :
image : caddy:2-alpine
restart : unless-stopped
depends_on :
- server
- dashboard
ports :
- "80:80"
- "443:443"
volumes :
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes :
mysql_data :
redis_data :
caddy_data :
caddy_config :
MySQL and Redis only bind to 127.0.0.1, so they are not exposed to the internet. The dashboard uses http://server:3001 (Docker internal DNS) for server-side rendering, avoiding a round trip through the public internet.
Astro build arg
Astro inlines PUBLIC_ environment variables at build time. The Dockerfile must accept it as a build arg:
ARG PUBLIC_SUPAPROXY_API_URL
ENV PUBLIC_SUPAPROXY_API_URL=$PUBLIC_SUPAPROXY_API_URL
RUN pnpm build
Without this, the dashboard returns 500 on every request.
Managing the deployment
Redeploy after code changes
ssh root@YOUR_DROPLET_IP
cd supaproxy-infra
# Pull latest
cd supaproxy-server && git pull && cd ..
cd supaproxy-dashboard && git pull && cd ..
# Rebuild and restart
docker compose up -d --build
Redeploy a single service
cd supaproxy-server && git pull && cd ..
docker compose up -d --build server
View logs
docker compose logs -f # All services
docker compose logs -f server # Server only
docker compose logs -f dashboard # Dashboard only
docker compose logs --tail 50 caddy # Last 50 Caddy lines
Check resource usage
Stop everything
docker compose down
# Data is preserved in Docker volumes
SSL troubleshooting
Caddy provisions Let’s Encrypt certificates automatically. If it fails:
Symptom Cause Fix Invalid response from /.well-known/acme-challengeDNS resolves to multiple IPs Remove extra A records, keep only the droplet IP remote error: tls: no application protocolSame as above, or DNS not propagated Wait a few minutes, then docker compose restart caddy Cert works for one domain but not the other Missing A record for api subdomain Add the A record and restart Caddy
Check certificate status:
docker compose logs caddy | grep "certificate obtained"
Backups
MySQL
# Manual backup
docker exec supaproxy-infra-mysql-1 \
mysqldump -u root -p " $DB_PASSWORD " supaproxy | gzip > backup.sql.gz
Automate with cron:
# crontab -e
0 3 * * * docker exec supaproxy-infra-mysql-1 mysqldump -u root -p "$( grep DB_PASSWORD /root/supaproxy-infra/.env | cut -d= -f2 )" supaproxy | gzip > /root/backups/supaproxy- $( date + \% Y \% m \% d ) .sql.gz
Scaling
When to upgrade
Monitor with docker stats. Upgrade when:
Memory consistently above 80%
CPU sustained above 70%
Disk approaching 80% (df -h)
DigitalOcean lets you resize droplets with a few clicks.
Splitting services
When a single droplet is no longer enough:
Move MySQL to a managed database
$15/month on DigitalOcean. Update DB_HOST in docker-compose. Gives you automated backups and replication.
Move Redis to a managed cache
If queue throughput becomes a bottleneck.
Run multiple app droplets
Put them behind a DigitalOcean Load Balancer. The server and dashboard are stateless (sessions in JWT cookies, jobs in Redis).
CI/CD
Automate deploys on merge to main:
# .github/workflows/deploy.yml
name : Deploy
on :
push :
branches : [ main ]
jobs :
deploy :
runs-on : ubuntu-latest
steps :
- name : Deploy to droplet
uses : appleboy/ssh-action@v1
with :
host : ${{ secrets.DROPLET_IP }}
username : root
key : ${{ secrets.SSH_PRIVATE_KEY }}
script : |
cd /root/supaproxy-infra
cd supaproxy-server && git pull && cd ..
cd supaproxy-dashboard && git pull && cd ..
docker compose up -d --build
Cost
Item Cost DigitalOcean droplet (2 GB) $12/month Domain ~$10/year SSL certificates Free (Let’s Encrypt) Total ~$13/month