Skip to main content
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:
ServicePurposeImage
CaddyReverse proxy + auto SSLcaddy:2-alpine
DashboardAstro SSR frontendBuilt from supaproxy-dashboard
ServerHono API backendBuilt from supaproxy-server
MySQLPersistent storagemysql:8
RedisBackground job queuesredis: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

  1. Create > Droplets
  2. Region: pick the closest to your users
  3. Image: Ubuntu 24.04 LTS
  4. Size: Basic > Regular > $12/month (2 GB RAM, 1 vCPU)
  5. Authentication: select your SSH key
  6. 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:
TypeNameValue
A@YOUR_DROPLET_IP
AapiYOUR_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.

Step 5: Clone and configure

ssh root@YOUR_DROPLET_IP
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

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

docker stats

Stop everything

docker compose down
# Data is preserved in Docker volumes

SSL troubleshooting

Caddy provisions Let’s Encrypt certificates automatically. If it fails:
SymptomCauseFix
Invalid response from /.well-known/acme-challengeDNS resolves to multiple IPsRemove extra A records, keep only the droplet IP
remote error: tls: no application protocolSame as above, or DNS not propagatedWait a few minutes, then docker compose restart caddy
Cert works for one domain but not the otherMissing A record for api subdomainAdd 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:
1

Move MySQL to a managed database

$15/month on DigitalOcean. Update DB_HOST in docker-compose. Gives you automated backups and replication.
2

Move Redis to a managed cache

If queue throughput becomes a bottleneck.
3

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

ItemCost
DigitalOcean droplet (2 GB)$12/month
Domain~$10/year
SSL certificatesFree (Let’s Encrypt)
Total~$13/month