Deploying AI-Native Apps to a DigitalOcean VPS
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.
| Service | How it runs |
|---|---|
| PostgreSQL | Native (apt) |
| Redis | Native (apt) |
| Qdrant | Docker with named volume |
| FastAPI backend | Docker, network_mode: host |
| Celery worker | Docker, network_mode: host |
| Celery beat | Docker, network_mode: host |
| Next.js frontend | Docker, network_mode: host |
| Nginx | Native (reverse proxy) |
| Certbot | Native (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 dockerPhase 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';
\qRedis
sudo apt install -y redis-server
sudo systemctl enable redis-server
sudo systemctl start redis-server
redis-cli ping # -> PONGQdrant
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/healthPhase 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=productionstart.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.shPhase 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.comNginx 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 nginxPhase 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.shDockerfile 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-ubuntuandttf-ubuntu-font-familyare not available, just leave them outttf-unifontis calledfonts-unifontnow- Skip
playwright install-depsentirely, 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
sendgridPython 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 nginxQuick Reference
| Task | Command |
|---|---|
| Start / redeploy all | ./start.sh |
| Check running containers | docker ps |
| View backend logs | docker logs root-backend-1 -f |
| View worker logs | docker logs root-worker-1 -f |
| Restart a container | docker restart root-backend-1 |
| Reload Nginx | sudo systemctl reload nginx |
| Test Nginx config | sudo nginx -t |
| Check SSL cert | sudo certbot certificates |
| Renew SSL (dry run) | sudo certbot renew --dry-run |
| Redis ping | redis-cli ping |
| Qdrant health | curl http://localhost:6333/health |