Deployment & ops
How Setu actually runs today — the live v0.1 stack across four managed services — the image, the entrypoint, the full environment matrix, the cross-site auth setup, CI/CD, and the two self-hosting paths (one-box Compose, BYO-Postgres Helm).
Setu is a Go control plane (apiserver + worker, one Docker image), a Next.js console, Postgres, and an S3-compatible blob store — the staging seam between cloud and edge. The v0.1 deployment stitches four managed free/low-tiers together· nothing here is exotic, and the same image self-hosts unchanged.
The live v0.1 stack
| Console | Vercel — Next.js, static NEXT_PUBLIC_API_BASE_URL baked at build |
|---|---|
| Control plane | Render — apiserver + worker in one container (plan: free) |
| Database | Neon Postgres — metadata + the River job queue + scheduler state |
| Blob seam | Supabase Storage (S3-compatible) — staged parts + manifests |
| Heartbeat | GitHub Actions cron pings /internal/scheduler/tick every 5 min |
| Edge runtimes | installed by users· connect outbound-only to the Render URL |
The image — one multi-target Dockerfile
A single Dockerfile builds both Go services from the go.work workspace in one pass, then exposes three runtime targets. Render (and the combined self-host box) use the default combined target· Kubernetes picks apiserver and worker separately.
# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/apiserver ./apiserver/cmd/apiserver \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/worker ./worker/cmd/worker
FROM alpine:3.20 AS runtime
RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 setu
WORKDIR /app
USER setu # non-root (uid 10001)
FROM runtime AS apiserver # k8s / compose
COPY --from=build /out/apiserver /app/apiserver
ENV API_ADDR=":8080"
ENTRYPOINT ["/app/apiserver"]
FROM runtime AS worker # k8s / compose
COPY --from=build /out/worker /app/worker
ENTRYPOINT ["/app/worker"]
FROM runtime AS combined # DEFAULT — free single-service hosting (Render)
COPY --from=build /out/apiserver /out/worker /app/
COPY --chmod=0755 deploy/entrypoint.sh /app/entrypoint.sh
CMD ["/app/entrypoint.sh"]The combined target runs both binaries from one entrypoint, and the ordering is deliberate: the apiserver applies the DB + River migrations and starts serving on $PORT (Render injects it· API_ADDR becomes :$PORT), and only once /healthz passes does the worker start — it needs the River tables, so starting it first would crash-loop. If the apiserver exits, the container exits and the platform restarts it.
export API_ADDR=":${PORT:-8080}"
./apiserver &
API_PID=$!
# wait for migrations to finish (health = serving) before starting the worker
i=0; while [ "$i" -lt 90 ]; do
wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/healthz" && break
i=$((i + 1)); sleep 1
done
# keep the worker alive next to the apiserver
( while true; do ./worker || echo "worker exited ($?); restarting" >&2; sleep 3; done ) &
wait "$API_PID"Configuration & environment
The Go services (apiserver + worker) share one env set· a few keys are apiserver-only, and the edge runtime reads its own. Secrets are set in each provider’s dashboard (sync: false in the Render blueprint), never committed. Everything has a local-dev default, so the matrix below is what you set in production.
Core & database
DATABASE_URL | Postgres DSN (Neon). Migrations + River tables auto-apply on boot. Dev default postgresql://setu:setu@localhost:5432/setu. Both services. |
|---|---|
SETU_SECRET_KEY | 32-byte base64 (openssl rand -base64 32) for the encrypted cloud secret store. Generate once, keep stable — rotating it strands existing ciphertext. Both. |
API_ADDR | Listen address· defaults :8080. The combined entrypoint rewrites it to :$PORT. apiserver. |
API_LOG_LEVEL | Structured-log level· defaults info. apiserver. |
Cross-site auth & URLs
SETU_COOKIE_CROSS_SITE | true when console and API are different sites → session cookie becomes SameSite=None; Secure. Anything else ⇒ SameSite=Lax (Secure only on HTTPS). apiserver. |
|---|---|
SETU_CORS_ORIGINS | Comma-separated allowlist of console origins for credentialed CORS· parsed once at startup. Unset ⇒ dev fallback http://localhost:3000,http://127.0.0.1:3000. apiserver. |
SETU_PUBLIC_URL | Public URL agents dial· baked into the enroll command shown in the console. apiserver. |
SETU_WEB_ORIGIN | Console base URL used to build invite links in emails· dev default http://localhost:3000. apiserver. |
NEXT_PUBLIC_API_BASE_URL | The apiserver URL, baked into the console at build time. web. |
Blob seam (staging)
The blob backend is chosen by SETU_BLOB_BACKEND: fs (a local directory, single-host only) or s3 (any S3-compatible store). The S3 keys are also handed to trusted edge agents as a serialized blob.Spec so both sides stage to the same bucket.
SETU_BLOB_BACKEND | fs (default) or s3. Both. |
|---|---|
SETU_BLOB_FS_ROOT | Directory root· required when backend is fs. Both. |
SETU_BLOB_S3_ENDPOINT / _REGION / _BUCKET | S3 endpoint, region, and bucket for the staging store. Both. |
SETU_BLOB_S3_ACCESS_KEY / _SECRET_KEY | S3 credentials. Both. |
SETU_BLOB_S3_FORCE_PATH_STYLE | true for path-style buckets (Supabase, MinIO). Both. |
SETU_BLOB_RETENTION_DAYS | TTL for staged parts before GC· default 7. Both. |
Ops & execution tuning
SETU_TICK_TOKEN | Bearer token guarding /internal/scheduler/tick. Unset ⇒ the route 404s, so it’s never left open. apiserver. |
|---|---|
SETU_STAGED_EXECUTION | Kill switch· any value except off keeps staged execution (default). off reverts to the legacy single-pass copy. worker. |
SETU_DIRECT_MAX_ROWS | Incremental-delta threshold (rows) at or below which a run streams direct (no blob). Default 50000· 0 ⇒ always stage. worker. |
Edge runtime (agent-side)
SETU_CONTROL_PLANE_URL | Control-plane base URL the agent dials· default http://localhost:8080. Set at setu enroll time. |
|---|---|
SETU_AGENT_CONFIG | Path to the agent’s local config (agent key, blob spec, secret refs). |
| Edge secrets | Set locally with setu secret set NAME value — an encrypted local store, a Postgres pgpass, a token, or Vault (SETU_VAULT_MOUNT / SETU_VAULT_PREFIX). Never sent to the cloud. |
services:
- type: web
name: setu-api
runtime: docker
dockerfilePath: ./Dockerfile # default target = combined
plan: free
healthCheckPath: /healthz
autoDeploy: true # rebuild + roll on push to main
envVars:
- { key: DATABASE_URL, sync: false } # Neon
- { key: SETU_SECRET_KEY, sync: false } # openssl rand -base64 32
- { key: SETU_PUBLIC_URL, sync: false } # this service's Render URL
- { key: SETU_COOKIE_CROSS_SITE, value: "true" }
- { key: SETU_CORS_ORIGINS, sync: false } # e.g. https://setu.vercel.app
- { key: SETU_BLOB_BACKEND, value: s3 }
- { key: SETU_BLOB_S3_ENDPOINT, sync: false }
- { key: SETU_BLOB_S3_REGION, sync: false }
- { key: SETU_BLOB_S3_BUCKET, value: setu-staging }
- { key: SETU_BLOB_S3_ACCESS_KEY, sync: false }
- { key: SETU_BLOB_S3_SECRET_KEY, sync: false }
- { key: SETU_BLOB_S3_FORCE_PATH_STYLE, value: "true" }Cross-site auth
The console (Vercel) and API (Render) live on different sites, so the session cookie has to survive a cross-site fetch. Two things make that work, together:
- The cookie. With
SETU_COOKIE_CROSS_SITE=truethe apiserver sets thehttpOnlysession cookieSameSite=None; Secure— the only combination browsers send on cross-site requests (Noneis only honored withSecure). Local dev falls back toSameSite=Lax. - CORS. Credentialed CORS can’t use a wildcard origin, so the server echoes the request
Originonly when it’s in the startup allowlist fromSETU_CORS_ORIGINS, alongsideAccess-Control-Allow-Credentials: trueand aVary: Origin. Unknown origins get a non-credentialed wildcard the browser refuses cookies for.
if _, ok := allow[origin]; ok && origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
} else {
w.Header().Set("Access-Control-Allow-Origin", "*") // browsers refuse cookies here
}httpOnly and cross-site, a plain <a href> to a download URL won’t carry it. Staged-artifact downloads (/api/v1/runs/{id}/artifacts/{stream}) go through a credentialed fetch in the console, not a naked link.CI/CD
Four GitHub Actions workflows. App deploys are provider-driven (Render + Vercel auto-deploy on push to main)· the workflows cover verification, the agent release, docs, and the heartbeat.
ci.yml | On PR + push to main: a Go matrix (build/vet/test -race) and a separate golangci-lint matrix across the 5 modules, plus a web job (lint/typecheck/test/build). cancel-in-progress per ref. |
|---|---|
release.yml | On a v* tag: GoReleaser cross-compiles the setu runtime and publishes assets to the public setu-dist repo. |
heartbeat.yml | Every 5 min: POST /internal/scheduler/tick so due schedules fire and the sleepy host wakes. |
docs.yml | On docs-site/** changes: next build + Pagefind index → GitHub Pages. |
jobs:
go:
strategy:
matrix:
module: [engine, apiserver, worker, agent, packs]
steps:
- uses: actions/setup-go@v5
with: { go-version: "1.26" }
- run: cd ${{ matrix.module }} && go build ./...
- run: cd ${{ matrix.module }} && go vet ./...
- run: cd ${{ matrix.module }} && go test ./... -race -count=1on:
schedule:
- cron: "*/5 * * * *" # GitHub's minimum granularity
jobs:
tick:
steps:
- run: |
url="${API_URL:-https://setu-api-f776.onrender.com}"
curl -fsS -X POST "$url/internal/scheduler/tick" \
-H "Authorization: Bearer ${TICK_TOKEN}" \
--max-time 120 --retry 4 --retry-all-errors --retry-delay 10The tick handler 404s when SETU_TICK_TOKEN is unset, does a constant-time compare of the bearer token, then runs one scheduler pass and returns 204. The agent binary ships via tag → GoReleaser → the public setu-dist repo (source stays private). See From code to deploy for the full flow.
Self-hosting
The same multi-target Dockerfile builds the Go services· the console is built per API URL (NEXT_PUBLIC_* is baked in). Two supported paths.
Option A — Docker Compose (one box)
Postgres + MinIO (S3) + apiserver + worker + console, self-contained on one machine.
cd deploy/compose
export SETU_SECRET_KEY="$(openssl rand -base64 32)"
# remote access: also export SETU_PUBLIC_URL="https://api.your-domain.com"
docker compose -f docker-compose.selfhost.yml up --build
# console :3000 · API :8080 · MinIO console :9001setu/setu, MinIO setu/setu-secret). Change every default password before exposing the box.Option B — Kubernetes (Helm)
Bring your own Postgres (e.g. Neon) and S3 blob (e.g. Supabase/R2/MinIO). The chart pulls the apiserver and worker images from ghcr.io/abhishekkumar2021/setu (tags apiserver-latest / worker-latest) and renders: an apiserver Deployment + Service + Ingress (TLS via setu-api-tls), a worker Deployment, a Secret, and a ServiceAccount.
helm upgrade --install setu deploy/helm/setu \
--namespace setu --create-namespace \
--set apiserver.publicUrl=https://api.your-domain.com \
--set ingress.host=api.your-domain.com \
--set config.secret.DATABASE_URL='postgres://user:pass@host/db?sslmode=require' \
--set config.secret.SETU_SECRET_KEY="$(openssl rand -base64 32)" \
--set config.secret.SETU_BLOB_S3_ENDPOINT=https://<proj>.supabase.co/storage/v1/s3 \
--set config.secret.SETU_BLOB_S3_BUCKET=setu-stagingPrefer a managed secret? Set config.existingSecret=<name> with the same keys and omit the config.secret.* flags. Deploy the console separately, built with the matching NEXT_PUBLIC_API_BASE_URL.
Edge runtimes need nothing central
There is no agent server to deploy. A user installs setu on their own host and walks a short flow· edge secrets stay local and never reach the control plane. The runtime dials SETU_CONTROL_PLANE_URL outbound over HTTPS 443 — no inbound ports.
- 1Register. In the console (Agents → Register runtime) mint a one-time enrollment token.
- 2Enroll.
setu enroll <token>exchanges it for a persistent, hashed, revocable agent API key stored locally. - 3Set secrets.
setu secret set NAME value— resolved on the edge at run time· the cloud only ever sees${secret.NAME}refs. - 4Run.
setu runlong-polls the control plane for leased jobs, executes them near the data, stages to blob, and reports metadata back.
SameSite=None; Secure cookie with an explicit CORS allowlist. Push to main auto-deploys· a v* tag ships the agent via GoReleaser· a GitHub cron heartbeats the scheduler and wakes the free-tier host. Self-host the identical image with Compose (one box) or Helm (BYO Postgres + S3).