Files
ProjectE/DEPLOY.md
T

7.8 KiB

Project E — Deploy Guide

Production runs as a docker-compose stack on the deploy host (10.0.0.52). The stack has four services: PostgreSQL, the Hono API, the Vite SPA served by Caddy, and the Bun worker. Deploys are normally driven by Gitea Actions, but every step below can be run by hand.

Architecture

Internet → :3000 (SPA container, Caddy)
              ├── /api/* → api:3000 (Hono/Bun)
              ├── /mcp*  → api:3000 (Hono/Bun)
              └── /*     → index.html (SPA fallback)

API (:3001, direct) → PostgreSQL (:5432)
Worker → PostgreSQL

Caddy (in the spa container) serves the built SPA and reverse-proxies /api/* and /mcp* to the api service. The API is also exposed directly on :3001 for debugging.

Services

Service Container Image / Build Ports Notes
db project-e-db postgres:16-alpine 5432 Data in the project-e-pg-data volume
api project-e-api Dockerfile.api 3001 → 3000 Hono on Bun
spa project-e-spa Dockerfile.spa 3000 → 80 Built Vite SPA + Caddy
worker project-e-worker Dockerfile.worker none Bun worker

All services share the project-e-network bridge and restart unless stopped.

CI/CD pipeline (Gitea Actions)

The workflow lives at .gitea/workflows/ci.yml and runs on the self-hosted runner projecte-runner, which is colocated with the deploy host. The runner must be registered once (see "Setting up the runner" below) before any job can run.

  • quality — runs on every push and pull request: bun install --frozen-lockfilebun run typecheck → web build (cd apps/web && bun run build) → docker compose build. A failed quality gate blocks the deploy job.
  • deploy — runs on pushes to main and on workflow_dispatch. It checks out the code and runs bash script/deploy.sh with DEPLOY_DIR defaulting to /home/projecte/ProjectE.
  • smoke — runs after deploy (also on deploy failure): API health at http://localhost:3000/api/health, SPA root returns HTML, and a login POST to /api/auth/credentials using INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD from the host .env.

What script/deploy.sh does

Idempotent, safe to re-run. It:

  1. Syncs the CI checkout into DEPLOY_DIR (rsync, excluding .git, node_modules, build artifacts, and .env)
  2. Loads secrets from the host .env (never overwrites it)
  3. Installs dependencies with bun install --frozen-lockfile
  4. Runs bun run db:migrate
  5. Runs docker compose build and docker compose up -d
  6. Waits up to 60s for http://localhost:3000/api/health to return HTTP 200, dumping recent API logs if it times out

Setting up the runner (one-time)

The workflow runs on the self-hosted runner projecte-runner, which must be registered on the Gitea instance before any job can start (otherwise jobs stay stuck at "Waiting to run"). Do this once on the deploy host (10.0.0.52), which is where the deploy and smoke jobs act on the local docker-compose stack:

  1. Install act_runner — download the act_runner binary from the Gitea Releases page matching your server version, or use the official gitea/act_runner Docker image. (Gitea Actions uses act_runner; the exact binary/URL depends on your Gitea version.)

  2. Create a registration token — in the Gitea web UI go to Repository → Settings → Actions → Runners → Create new runner and copy the token.

  3. Register with a label that matches the workflow's runs-on:

    ./act_runner register \
      --instance https://git.buzzbee.dev \
      --token <REGISTRATION_TOKEN> \
      --name projecte-runner \
      --labels projecte-runner:host
    

    The label name projecte-runner must match .gitea/workflows/ci.yml; the executor (host or docker) is your choice — host is simplest for a single colocated runner.

  4. Start it — run ./act_runner daemon (or install it as a systemd service so it survives reboots).

Verify: the Runners page shows it online, then re-trigger the pipeline (a push to main, or "Re-run" on the Actions tab). The quality job should leave "Waiting to run". If jobs stay queued, the runner is offline or its label does not match runs-on: projecte-runner — check the act_runner logs.

Secrets

Secrets live in the host .env at /home/projecte/ProjectE/.env. This file is gitignored; never commit it. To set it up:

cp .env.example .env

Fill in POSTGRES_PASSWORD, DATABASE_URL, AUTH_SECRET (or NEXTAUTH_SECRET), INITIAL_ADMIN_EMAIL, INITIAL_ADMIN_PASSWORD, and, as needed, NODE_ENV, PUBLIC_URL, COOKIE_SECURE, and ALLOWED_HOSTS. deploy.sh sources it, and docker-compose reads the POSTGRES_PASSWORD and DATABASE_URL values from it.

Manual deploy

On the deploy host:

cd /home/projecte/ProjectE

# Pull latest
git pull origin main

# Install dependencies
bun install

# Apply schema + triggers (idempotent)
bun run db:migrate

# Build images
docker compose build

# Restart the stack
docker compose up -d

# Wait for API health
until curl -s http://localhost:3000/api/health | grep -q '"status"'; do sleep 2; done

# Check status
docker compose ps

Database migrations

bun run db:migrate chains two idempotent steps:

  • db:syncdrizzle-kit push --force, which syncs the schema in packages/db/src/schema.ts to the database
  • db:triggersscript/apply-triggers.ts, which applies the search-vector triggers from drizzle/0005_search_vector_trigger.sql (CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS)

Both are safe to run on every deploy. Schema changes go through bun run db:generate in development, then land in drizzle/ before the next deploy.

Rollback

Compose images are rebuilt from the checkout, so there are no pinned image tags to restore. To roll back a bad release:

  1. Revert the checkout to the previous good commit: git revert <sha> (or git checkout <sha>) and push to main
  2. Re-run the manual deploy steps (docker compose build, docker compose up -d)

The project-e-pg-data volume is untouched by deploys and rollbacks, so the database survives both. If a deploy failed, deploy.sh exits non-zero with the recent API logs; do not force it past a failing health check.

Logs

# All services
docker compose logs --tail=50 -f

# Specific service
docker compose logs --tail=50 -f api
docker compose logs --tail=50 -f spa
docker compose logs --tail=50 -f worker
docker compose logs --tail=50 -f db

Debugging

API health (direct, :3001)

curl http://localhost:3001/api/health

Returns {"status":"ok", ...} with a database ping (database.connected, database.ping_ms).

API health (through Caddy)

curl http://localhost:3000/api/health

SPA health check

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/

Login test

curl -s -X POST http://localhost:3000/api/auth/credentials \
  -H "Content-Type: application/json" \
  -d '{"email":"<INITIAL_ADMIN_EMAIL>","password":"<INITIAL_ADMIN_PASSWORD>"}'

Expect HTTP 200 and a session cookie.

Container health

docker inspect project-e-db --format '{{.State.Health.Status}}'

Restart or rebuild one service

docker compose restart api

docker compose build spa
docker compose up -d --force-recreate spa

Important notes

  • The project-e-pg-data Docker volume contains the live database. Do not delete it. Back it up (volume snapshot or pg_dump) before major schema work.
  • Port 3000 is the SPA (Caddy); port 3001 is the API directly (for debugging).
  • The MCP endpoint requires a valid API key (separate from JWT auth).
  • The root worker/ directory is legacy. The active worker is apps/worker.