FastAPI backend with Gunicorn/Uvicorn, Celery, Redis, SQLModel, and Alembic.
Install uv, then run:
cp .env.example .env
uv sync
uv run alembic upgrade head
make devThe API is available at http://localhost:8000. Start Redis and the Celery
worker in a second terminal if background jobs are needed:
make workerThe following guide targets a current Ubuntu or Debian server. It uses:
/opt/agent-backendfor the applicationagent-backendas a dedicated system user- Gunicorn with the
uvicorn-workerASGI worker - systemd for the API and Celery processes
- Nginx as the public reverse proxy
- Certbot for HTTPS
Replace api.example.com with the API domain throughout this guide. Point the
domain's DNS A/AAAA record at the VPS before requesting a certificate.
Log in with a sudo-capable account:
sudo apt update
sudo apt install -y curl git make nginx redis-server snapd
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
uv --versionEnable Redis now:
sudo systemctl enable --now redis-server
sudo systemctl status redis-server --no-pagersudo useradd \
--system \
--create-home \
--home-dir /var/lib/agent-backend \
--shell /usr/sbin/nologin \
agent-backend
sudo install -d \
-o agent-backend \
-g agent-backend \
/opt/agent-backend
sudo -u agent-backend -H git clone \
https://github.com/Devscale-Indonesia/agent-backend.git \
/opt/agent-backendFor a private repository, configure a read-only deploy key for the
agent-backend user or upload a release artifact instead of cloning over
HTTPS.
Install the exact locked production dependencies:
cd /opt/agent-backend
sudo -u agent-backend -H /usr/local/bin/uv sync --locked --no-devuv installs the compatible Python version automatically when the server does
not already provide it.
cd /opt/agent-backend
sudo cp .env.example .env
sudo nano .envAt minimum, review and replace these values:
APP_NAME=Agent Backend API
DEBUG=false
# Four slashes make this an absolute SQLite path.
DATABASE_URL=sqlite:////opt/agent-backend/app.db
# Use the frontend origin, not the API origin. Add more values if required.
CORS_ORIGINS=["https://app.example.com"]
TAVILY_API_KEY=replace-me
LITELLM_BASE_URL=
LITELLM_API_KEY=replace-me
LITELLM_MODEL=gpt-5.4-mini
AUTH_SECRET_KEY=replace-with-a-long-random-secret
AUTH_ALGORITHM=HS256
AUTH_ACCESS_TOKEN_EXPIRE_MINUTES=60
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/1Generate a suitable authentication secret with openssl rand -hex 32, paste
the output into AUTH_SECRET_KEY, and then protect the file:
sudo chown agent-backend:agent-backend /opt/agent-backend/.env
sudo chmod 600 /opt/agent-backend/.envRun the database migrations as the service user:
cd /opt/agent-backend
sudo -u agent-backend -H .venv/bin/alembic upgrade headCreate /etc/systemd/system/agent-backend.service:
[Unit]
Description=Agent Backend API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=agent-backend
Group=agent-backend
WorkingDirectory=/opt/agent-backend
Environment=PYTHONUNBUFFERED=1
UMask=0077
ExecStart=/opt/agent-backend/.venv/bin/gunicorn app.main:app \
--worker-class uvicorn_worker.UvicornWorker \
--workers 1 \
--bind 127.0.0.1:8000 \
--access-logfile - \
--error-logfile -
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.targetThe service uses one web worker because the repository currently defaults to SQLite. Multiple processes writing to SQLite can cause lock contention. Keep this at one until the application is configured and tested with a production database server and its appropriate async driver.
The process binds only to 127.0.0.1; external clients must go through Nginx.
Pydantic loads /opt/agent-backend/.env because systemd sets that directory as
WorkingDirectory.
Create /etc/systemd/system/agent-backend-worker.service:
[Unit]
Description=Agent Backend Celery Worker
After=network-online.target redis-server.service
Wants=network-online.target
Requires=redis-server.service
[Service]
Type=simple
User=agent-backend
Group=agent-backend
WorkingDirectory=/opt/agent-backend
Environment=PYTHONUNBUFFERED=1
UMask=0077
ExecStart=/opt/agent-backend/.venv/bin/celery \
-A app.worker:celery_app \
worker \
--loglevel=info
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.targetIf Redis is hosted on another server, update the two Redis URLs in .env and
remove Requires=redis-server.service and redis-server.service from After.
Load and start both units:
sudo systemctl daemon-reload
sudo systemctl enable --now agent-backend.service
sudo systemctl enable --now agent-backend-worker.service
sudo systemctl status agent-backend.service --no-pager
sudo systemctl status agent-backend-worker.service --no-pager
curl http://127.0.0.1:8000/healthThe health endpoint should return:
{"status":"ok"}View live logs with:
sudo journalctl -u agent-backend.service -f
sudo journalctl -u agent-backend-worker.service -fCreate /etc/nginx/sites-available/agent-backend:
server {
listen 80;
listen [::]:80;
server_name api.example.com;
client_max_body_size 10m;
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;
# Chat responses are streamed; send chunks to clients immediately.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}Enable the site and validate the configuration before reloading:
sudo ln -s /etc/nginx/sites-available/agent-backend \
/etc/nginx/sites-enabled/agent-backend
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl enable --now nginx
sudo systemctl reload nginx
curl http://api.example.com/healthIf UFW is used, allow SSH before enabling it so the current session is not locked out. If SSH uses a custom port, allow that port instead of the standard OpenSSH profile.
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw statusPort 8000 does not need to be opened because Gunicorn listens only on the
loopback interface.
After DNS resolves to the VPS and HTTP works:
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/local/bin/certbot
sudo certbot --nginx -d api.example.com
sudo certbot renew --dry-runCertbot edits the Nginx server block for TLS and installs automatic renewal.
Afterward, verify https://api.example.com/health and update CORS_ORIGINS if
the frontend production URL changed.
Pull only fast-forward changes, sync the locked dependencies, run migrations, and restart both processes:
cd /opt/agent-backend
sudo -u agent-backend -H git pull --ff-only origin main
sudo -u agent-backend -H /usr/local/bin/uv sync --locked --no-dev
sudo -u agent-backend -H .venv/bin/alembic upgrade head
sudo systemctl restart agent-backend.service agent-backend-worker.service
sudo systemctl status agent-backend.service --no-pager
sudo systemctl status agent-backend-worker.service --no-pager
curl https://api.example.com/healthFor 502 Bad Gateway, first confirm that the API is running and listening:
sudo systemctl status agent-backend.service --no-pager
sudo journalctl -u agent-backend.service -n 100 --no-pager
curl -v http://127.0.0.1:8000/health
sudo tail -n 100 /var/log/nginx/error.logFor failed background jobs, check Redis and the Celery worker:
sudo systemctl status redis-server agent-backend-worker.service --no-pager
sudo journalctl -u agent-backend-worker.service -n 100 --no-pager
redis-cli pingredis-cli ping should return PONG.