Deployment
Deploy with Docker
Package the API and the poller as two Docker Compose services from one image, clone the repo with a deploy key, and run it on the VPS.
By the end of this page the poller runs on your server and answers email while your laptop is off. The API runs next to it, reachable only from the server itself.
| Service | Command | Port |
|---|---|---|
api | uvicorn app.main:app | 127.0.0.1:8080 only |
poller | python -m app.poller | none |
Both services use the same image. Docker restarts each one if it crashes, and each has its own logs.
Write the Dockerfile
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONUNBUFFERED=1 UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY app ./app
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]--no-dev leaves out the Gmail login script dependencies and the evals. PYTHONUNBUFFERED makes log lines show up right away in docker compose logs.
.env
.venv
.git
credentials.json
supabase
evals
knowledge_base
dashboard/node_modules
dashboard/distWrite the Compose file
x-app: &app
build: .
image: ai-customer-support
env_file: .env
restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
services:
api:
<<: *app
ports:
- "127.0.0.1:8080:8080"
poller:
<<: *app
command: ["python", "-m", "app.poller"]Always bind to 127.0.0.1
A port published as 8080:8080 is open on every network interface, and Docker writes its own firewall rules that bypass ufw. 127.0.0.1:8080:8080 keeps the API reachable from the server only.
The log options cap each service at 30 MB of logs, so a small disk does not fill up over the months.
Push the repo to GitHub
Create a private repository named ai-customer-support on GitHub, without a README. Then push:
git add .
git commit -m "AI customer support"
git branch -M main
git remote add origin git@github.com:<your-github-user>/ai-customer-support.git
git push -u origin mainRun git status first and make sure .env and credentials.json are not listed.
Add a deploy key
A deploy key gives the server read access to this one repository and nothing else. On the server:
ssh-keygen -t ed25519 -f ~/.ssh/github-deploy -N "" -C "support-vps"
cat >> ~/.ssh/config <<'EOF'
Host github.com
IdentityFile ~/.ssh/github-deploy
IdentitiesOnly yes
EOF
cat ~/.ssh/github-deploy.pubOn GitHub, open the repository, then Settings, Deploy keys, Add deploy key. Paste the public key, name it support-vps, leave Allow write access unchecked, and save.
Clone the repo
cd ~
git clone git@github.com:<your-github-user>/ai-customer-support.gitType yes when SSH asks about GitHub's fingerprint. GitHub publishes its fingerprints in its docs if you want to compare.
Copy the environment file
The .env file never goes through Git. Copy it from your laptop:
scp .env support-vps:ai-customer-support/.env
ssh support-vps 'chmod 600 ai-customer-support/.env'Set POLL_INTERVAL_SECONDS in the server's .env to what you want in real use, for example 600.
Stop the local poller
Press Ctrl+C in the poller terminal on your laptop. Two pollers on one inbox work against each other, and each one sends replies.
Start it
On the server:
cd ~/ai-customer-support
docker compose up -d --build
docker compose psBoth services show the status Up, and only api lists a port, 127.0.0.1:8080->8080/tcp.
Follow the poller's logs:
docker compose logs -f pollerRestrict database access
Right now the database password is the only lock on Supabase. Add a second lock, so only your laptop and the server can connect.
- In the Supabase dashboard, open Database settings and find Network restrictions.
- Add your laptop's IP as
<YOUR_IPV4>/32and the server's IP as<VPS_IPV4>/32. - Save.
Network restrictions cover Postgres and the pooler, not the HTTPS APIs. Those stay closed because RLS is on and there are no policies.
If your Supabase plan does not show Network restrictions, skip this step. RLS and a strong database password still protect the data.
From then on, supabase db push and the evals only work from your laptop's IP address.
Check
Run the full test from outside:
- Send an email from your personal account to the demo address.
- Within one poll interval, the poller logs show the message as
processed. - The reply arrives in your personal inbox, in the same thread.
- The
eventstable in Supabase has the new row.
Check the API from the server:
curl http://127.0.0.1:8080/health
# {"status":"ok"}From your laptop, http://<VPS_IPV4>:8080 does not load. That is correct.
Ship an update
After you push a change to GitHub:
cd ~/ai-customer-support
git pull
docker compose up -d --buildCompose rebuilds the image and restarts both services. The ai-processed label in Gmail and the events in Supabase keep the state, so a restart loses nothing.