Datalumina

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.

ServiceCommandPort
apiuvicorn app.main:app127.0.0.1:8080 only
pollerpython -m app.pollernone

Both services use the same image. Docker restarts each one if it crashes, and each has its own logs.

Write the Dockerfile

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.

.dockerignore
.env
.venv
.git
credentials.json
supabase
evals
knowledge_base
dashboard/node_modules
dashboard/dist

Write the Compose file

docker-compose.yml
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 main

Run 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.pub

On 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.git

Type 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 ps

Both 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 poller

Restrict 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.

  1. In the Supabase dashboard, open Database settings and find Network restrictions.
  2. Add your laptop's IP as <YOUR_IPV4>/32 and the server's IP as <VPS_IPV4>/32.
  3. 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:

  1. Send an email from your personal account to the demo address.
  2. Within one poll interval, the poller logs show the message as processed.
  3. The reply arrives in your personal inbox, in the same thread.
  4. The events table 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 --build

Compose 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.

On this page