setu:: docs
Reference

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

ConsoleVercel — Next.js, static NEXT_PUBLIC_API_BASE_URL baked at build
Control planeRender — apiserver + worker in one container (plan: free)
DatabaseNeon Postgres — metadata + the River job queue + scheduler state
Blob seamSupabase Storage (S3-compatible) — staged parts + manifests
HeartbeatGitHub Actions cron pings /internal/scheduler/tick every 5 min
Edge runtimesinstalled by users· connect outbound-only to the Render URL
Why one image on the free tier
Render’s free web service sleeps after ~15 min idle and wakes on the next request. Packing the apiserver and worker into a single container keeps one thing to wake, and the external scheduler heartbeat (below) both fires due schedules and warms the host.

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.

Dockerfile — build once, three targetsdockerfile
# 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.

deploy/entrypoint.sh — apiserver first, then worker (excerpt)bash
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_URLPostgres DSN (Neon). Migrations + River tables auto-apply on boot. Dev default postgresql://setu:setu@localhost:5432/setu. Both services.
SETU_SECRET_KEY32-byte base64 (openssl rand -base64 32) for the encrypted cloud secret store. Generate once, keep stable — rotating it strands existing ciphertext. Both.
API_ADDRListen address· defaults :8080. The combined entrypoint rewrites it to :$PORT. apiserver.
API_LOG_LEVELStructured-log level· defaults info. apiserver.

Cross-site auth & URLs

SETU_COOKIE_CROSS_SITEtrue when console and API are different sites → session cookie becomes SameSite=None; Secure. Anything else ⇒ SameSite=Lax (Secure only on HTTPS). apiserver.
SETU_CORS_ORIGINSComma-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_URLPublic URL agents dial· baked into the enroll command shown in the console. apiserver.
SETU_WEB_ORIGINConsole base URL used to build invite links in emails· dev default http://localhost:3000. apiserver.
NEXT_PUBLIC_API_BASE_URLThe 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_BACKENDfs (default) or s3. Both.
SETU_BLOB_FS_ROOTDirectory root· required when backend is fs. Both.
SETU_BLOB_S3_ENDPOINT / _REGION / _BUCKETS3 endpoint, region, and bucket for the staging store. Both.
SETU_BLOB_S3_ACCESS_KEY / _SECRET_KEYS3 credentials. Both.
SETU_BLOB_S3_FORCE_PATH_STYLEtrue for path-style buckets (Supabase, MinIO). Both.
SETU_BLOB_RETENTION_DAYSTTL for staged parts before GC· default 7. Both.

Ops & execution tuning

SETU_TICK_TOKENBearer token guarding /internal/scheduler/tick. Unset ⇒ the route 404s, so it’s never left open. apiserver.
SETU_STAGED_EXECUTIONKill switch· any value except off keeps staged execution (default). off reverts to the legacy single-pass copy. worker.
SETU_DIRECT_MAX_ROWSIncremental-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_URLControl-plane base URL the agent dials· default http://localhost:8080. Set at setu enroll time.
SETU_AGENT_CONFIGPath to the agent’s local config (agent key, blob spec, secret refs).
Edge secretsSet 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.
render.yaml — the control-plane blueprintyaml
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=true the apiserver sets the httpOnly session cookie SameSite=None; Secure — the only combination browsers send on cross-site requests (None is only honored with Secure). Local dev falls back to SameSite=Lax.
  • CORS. Credentialed CORS can’t use a wildcard origin, so the server echoes the request Origin only when it’s in the startup allowlist from SETU_CORS_ORIGINS, alongside Access-Control-Allow-Credentials: true and a Vary: Origin. Unknown origins get a non-credentialed wildcard the browser refuses cookies for.
apiserver/internal/api/api.go — the allowlist echo (excerpt)go
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
}
Artifact downloads are authenticated fetches
Because the session cookie is 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.ymlOn 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.ymlOn a v* tag: GoReleaser cross-compiles the setu runtime and publishes assets to the public setu-dist repo.
heartbeat.ymlEvery 5 min: POST /internal/scheduler/tick so due schedules fire and the sleepy host wakes.
docs.ymlOn docs-site/** changes: next build + Pagefind index → GitHub Pages.
ci.yml — the Go matrix (excerpt)yaml
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=1
heartbeat.yml — external scheduler tick (excerpt)yaml
on:
  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 10

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

deploy/composebash
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 :9001
Change the defaults
The self-host compose ships throwaway credentials (Postgres setu/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.

deploy/helm/setubash
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-staging

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

  1. 1
    Register. In the console (Agents → Register runtime) mint a one-time enrollment token.
  2. 2
    Enroll. setu enroll <token> exchanges it for a persistent, hashed, revocable agent API key stored locally.
  3. 3
    Set secrets. setu secret set NAME value — resolved on the edge at run time· the cloud only ever sees ${secret.NAME} refs.
  4. 4
    Run. setu run long-polls the control plane for leased jobs, executes them near the data, stages to blob, and reports metadata back.
The whole deployment in one breath
Console on Vercel· control plane (apiserver+worker, one image, apiserver-then-worker entrypoint) on Render· Postgres on Neon· blob on Supabase S3. Cross-site sessions ride a 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).