Deploying AI-Native Apps to a DigitalOcean VPS

2026-05-18  ·  12 min read

← Back to Writing

I spent way too long debugging why everything worked locally but kept breaking on the VPS. Most of the issues were not obvious and the error messages were not helpful. This writeup covers exactly what we set up for Seyyo and the things that tripped us up along the way.

The stack is: FastAPI backend, Celery workers, Qdrant for vector search, Postgres and Redis running directly on the machine, and a Next.js frontend. All of it on one DigitalOcean Droplet behind Nginx.

Architecture Overview

Infrastructure services (Postgres, Redis) run natively on the VM. Application containers run in Docker with network_mode: host so they can talk to those services on localhost without any extra network config.

ServiceHow it runs
PostgreSQLNative (apt)
RedisNative (apt)
QdrantDocker with named volume
FastAPI backendDocker, network_mode: host
Celery workerDocker, network_mode: host
Celery beatDocker, network_mode: host
Next.js frontendDocker, network_mode: host
NginxNative (reverse proxy)
CertbotNative (SSL via Let's Encrypt)

network_mode: host is what makes this work. Without it, containers sit on a separate bridge network and localhost means something different inside the container. With it, they share the VM's network and can hit Postgres on port 5432 like normal.

Phase 1 — Install Docker (Official Repo)

Don't use apt install docker.io. That version doesn't include the Compose plugin and it's always behind. Install from Docker's official repo instead:

sudo apt remove -y docker docker-engine docker.io containerd runc docker-compose

sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release

sudo install -m 0755 -d /usr/share/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/docker.gpg
sudo chmod a+r /usr/share/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

sudo systemctl enable docker
sudo systemctl start docker

Phase 2 — Native Infrastructure

PostgreSQL

sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable postgresql
sudo systemctl start postgresql

sudo -u postgres psql
  ALTER USER postgres WITH PASSWORD 'your_password';
  \q

Redis

sudo apt install -y redis-server
sudo systemctl enable redis-server
sudo systemctl start redis-server
redis-cli ping   # -> PONG

Qdrant

docker volume create qdrant_storage

docker run -d \
  --name qdrant \
  --restart unless-stopped \
  -p 6333:6333 -p 6334:6334 \
  -v qdrant_storage:/qdrant/storage \
  qdrant/qdrant

curl http://localhost:6333/health

Phase 3 — Application Containers

docker-compose.yml

services:
  backend:
    image: your-registry/app:backend
    env_file: /root/.env
    ports:
      - "8000:8000"
    restart: unless-stopped
    network_mode: host

  worker:
    image: your-registry/app:backend
    command: celery -A app.workers.celery_app worker --loglevel=info
    env_file: /root/.env
    restart: unless-stopped
    network_mode: host

  beat:
    image: your-registry/app:backend
    command: celery -A app.workers.celery_app beat --loglevel=info
    env_file: /root/.env
    restart: unless-stopped
    network_mode: host

  frontend:
    image: your-registry/app:frontend
    ports:
      - "3000:3000"
    restart: unless-stopped
    network_mode: host
    environment:
      - NEXT_PUBLIC_API_URL=https://api.yourdomain.com

.env

DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/your_db
REDIS_URL=redis://localhost:6379/0
QDRANT_URL=http://localhost:6333
ANTHROPIC_API_KEY=sk-ant-...
SECRET_KEY=your_secret_key
ENVIRONMENT=production

start.sh

cat > /root/start.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail

COMPOSE="docker compose -f /root/docker-compose.yml"

echo "==> Pulling latest images..."
$COMPOSE pull

echo "==> Running DB migrations..."
$COMPOSE run --rm backend alembic upgrade head

echo "==> Starting all services..."
$COMPOSE up -d --remove-orphans

docker image prune -f
$COMPOSE ps
EOF

chmod +x /root/start.sh

Phase 4 — Nginx + SSL

sudo apt install -y nginx
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

# Issue certs using standalone mode (stop nginx first if it's running on port 80)
sudo certbot certonly --standalone -d yourdomain.com -d api.yourdomain.com

Nginx config at /etc/nginx/sites-available/yourapp:

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 80;
    server_name api.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name api.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
sudo rm /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Phase 5 — CI/CD with GitHub Actions

You need a passphrase-free deploy key. The CI runner has no way to enter a passphrase interactively, so if your key has one the action will just hang and time out.

ssh-keygen -t ed25519 -C "app-deploy" -f ~/.ssh/app-deploy -N ""

# Add public key to the VM
cat ~/.ssh/app-deploy.pub | ssh root@YOUR_VM_IP "cat >> ~/.ssh/authorized_keys"

Add VPS_HOST, VPS_USER, and VPS_SSH_KEY as repo secrets (VPS_SSH_KEY is the full contents of the private key file).

File: .github/workflows/deploy.yml

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: /root/start.sh

Dockerfile Gotchas (python:3.12-slim / Debian Trixie)

If your backend uses Playwright for scraping, Debian Trixie will give you headaches. A few things that don't exist there that you might expect:

  • fonts-ubuntu and ttf-ubuntu-font-family are not available, just leave them out
  • ttf-unifont is called fonts-unifont now
  • Skip playwright install-deps entirely, it's hardcoded to Ubuntu package names and breaks on Debian without useful errors

Install the Playwright deps yourself:

FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y \
    gcc libpq-dev curl fonts-unifont \
    libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \
    libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
    libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 \
    libpango-1.0-0 libcairo2 \
    --no-install-recommends && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install chromium

COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

The Gotchas Nobody Tells You

DigitalOcean Blocks Outbound SMTP

DO blocks port 25 on all Droplets at the network level. Not the OS level. So your app will look like it's sending emails and nothing will arrive. No error, no bounce, just silence.

The fix is to not use raw SMTP at all from a DO server. Use an email API instead:

  • SendGrid - the sendgrid Python library sends over HTTPS
  • Resend - clean API, has a good free tier
  • Postmark - best deliverability if you care about inbox rates

All of these go over HTTPS so DO doesn't block them. If you were thinking about running Postfix yourself, don't bother.

Some AI APIs Don't Work from DO IPs

This one took me a while to figure out because the errors look like auth failures. Some AI API providers block requests from cloud datacenter IP ranges. Groq is one of them. Calls from a DigitalOcean Droplet just fail.

If something works on your laptop but breaks on the VPS and you're getting 403s or connection errors, the IP is probably the issue, not your code.

What you can do:

  • Use Anthropic or OpenAI instead, they both work fine from DO
  • If you specifically need Groq, route those calls through a Vercel or Cloudflare Worker function since those run on different IP ranges
  • Residential proxy if you really have no other option

The Cert and Nginx Ordering Issue

If Nginx is already running on port 80 and you run certbot --standalone, it will fail. Certbot needs port 80 free to complete the domain challenge.

You can use --nginx mode but that requires your Nginx config to already have the domain set up, which requires the cert to already exist. Bit of a circle.

The simplest way around it: stop Nginx, get the cert, then start Nginx back up.

sudo systemctl stop nginx
sudo certbot certonly --standalone -d yourdomain.com -d api.yourdomain.com
sudo systemctl start nginx

Quick Reference

TaskCommand
Start / redeploy all./start.sh
Check running containersdocker ps
View backend logsdocker logs root-backend-1 -f
View worker logsdocker logs root-worker-1 -f
Restart a containerdocker restart root-backend-1
Reload Nginxsudo systemctl reload nginx
Test Nginx configsudo nginx -t
Check SSL certsudo certbot certificates
Renew SSL (dry run)sudo certbot renew --dry-run
Redis pingredis-cli ping
Qdrant healthcurl http://localhost:6333/health