Skip to content

Preview Environments

A preview is a short-lived, branch-scoped deployment of the full Sartiq stack, reachable on a public *.preview.sartiq.com URL. You use one when cliq local up --task isn't enough — when you need to share a working build with product, run an end-to-end demo with real OAuth, exercise Cloudflare image transforms, or reproduce a bug that only shows up with a real Postgres and a real R2 bucket. Previews are production-shaped: real Postgres, real Redis, real R2 with image transforms, real wildcard TLS — not just an exposed localhost. They are not staging: each one is ephemeral, leased, and dies on its own.


How it fits in

Environment Who runs it Lifecycle Data URL
Local Dev laptop Always, until you stop it Synthetic seed via cliq seed pull localhost:*
Preview Shared GCP VM (preview-host) Ephemeral, per-branch, leased + host auto-shutdown Per-preview Postgres DB + isolated R2 bucket, seeded from the same R2 dataset <code>.preview.sartiq.com
Staging Dedicated VM Always on Periodically cloned from prod staging-api.sartiq.com, etc.
Production Dedicated VM Always on Authoritative api.sartiq.com, etc.

What you get

Three public hostnames per preview, plus an internal compute service:

Service Hostname Backed by
Webapp <code>.preview.sartiq.com Caddy → preview-<code>-webapp:3000
Backend API api-<code>.preview.sartiq.com Caddy → preview-<code>-backend:8000
Media (R2) <code>.media.preview.sartiq.com Cloudflare R2 custom domain on the per-preview bucket

The media hostname is a real R2 custom domain, not the public pub-*.r2.dev shortcut — that's the only way Cloudflare's image transforms (/cdn-cgi/image/...) work the same as in production.

Compute API is internal-only

There is no compute-api-<code>.preview.sartiq.com. The compute_server runs inside the per-preview compose stack and is reached internally by the backend at compute_server:9000 over the per-preview Docker network. No Caddy route, no DNS, no external port.


Lifecycle

Walking through with sar-23 as the running example.

1. Create

cliq preview create sar-23

Your laptop resolves a branch for each of shootify-backend, shootify-webapp, and shootify-compute-server (defaulting to the branch matching the preview code, then falling back to dev), then asks the host to build the three Docker images on GitHub Actions and push them to GHCR. (Pass --local-build to build + push from your laptop instead — the legacy path, useful when iterating on a Dockerfile not yet on dev.) The preview-host pulls the images, allocates a per-preview Postgres user + two databases, claims a Redis index from the 1..127 pool, creates the per-preview R2 bucket, renders a compose file, and brings the stack up. The DB and bucket are seeded from sartiq-misc/seed-data/. End state: preview running, 3h lease, auto-shutdown gates clear.

2. Use

Visit https://sar-23.preview.sartiq.com. Tail logs for any service:

cliq preview logs sar-23 backend -f

Services are backend, scheduler, celery_worker, compute_server, compute_worker, webapp.

3. Iterate

Push new code to your branch, then:

cliq preview rebuild sar-23

Same image build + push + pull cycle, but the per-preview DB and R2 bucket are preserved. Pass --services backend,compute-server to rebuild only a subset of the repos, and --refresh-env to also pick up shared env values changed since create.

If you only need the running stack to pick up a cliq preview env set change (no new code), use cliq preview recreate sar-23 instead — it redeploys the existing images and re-snapshots env, no build.

4. Demo

cliq preview extend sar-23 2h    # push the GC lease out
cliq preview hold sar-23 1h      # block host auto-shutdown

These are independent. extend keeps the preview from being reaped; hold keeps the underlying VM from stopping. You generally want both before a demo. See Lease vs auto-shutdown below.

5. Destroy

cliq preview destroy sar-23

Drops both Postgres DBs, deletes the R2 bucket, deletes the preview-tagged image tags on GHCR. Or just walk away — when the lease expires, the host reaps the preview automatically.

If the preview-host itself is auto-stopped when you next try cliq preview create, the CLI dispatches the preview-wake.yml workflow in shootify-io/shootify-deployment and polls the host's /healthz until it's reachable. You can also wake the host unconditionally with cliq preview wake. The VM resumes with the previews that were running, so a stop is recoverable, not catastrophic.


Architecture

flowchart TB
    Internet([Internet])

    subgraph Host["preview-host VM (n2-standard-8 / sartiq-staging / europe-west8-b)"]
        direction TB

        subgraph Shared["Shared services (one compose project)"]
            Caddy[Caddy<br/>preview-caddy:tag]
            PG[(Postgres)]
            Redis[(Redis)]
        end

        subgraph Sar23["preview sar-23"]
            S23_be[backend]
            S23_sch[scheduler]
            S23_cw[celery_worker]
            S23_cs[compute_server]
            S23_co[compute_worker]
            S23_wa[webapp]
        end

        subgraph Ops43["preview ops-43"]
            O43_be[backend]
            O43_sch[scheduler]
            O43_cw[celery_worker]
            O43_cs[compute_server]
            O43_co[compute_worker]
            O43_wa[webapp]
        end
    end

    R2[(Cloudflare R2)]

    Internet --> Caddy
    Caddy --> S23_wa
    Caddy --> S23_be
    Caddy --> O43_wa
    Caddy --> O43_be

    S23_be --> PG
    S23_be --> Redis
    S23_be --> S23_cs
    O43_be --> PG
    O43_be --> Redis
    O43_be --> O43_cs

    S23_cs --> R2
    S23_co --> R2
    O43_cs --> R2
    O43_co --> R2

Shared services

One Postgres (per-preview DBs live inside), one Redis (per-preview DB index drawn from the 1..127 pool — index 0 is reserved for the shared internal bus), one Caddy. The Caddy image is custom — ghcr.io/shootify-io/preview-caddy — built to bake in the caddy-dns/cloudflare plugin, because stock Caddy can't solve DNS-01 and DNS-01 is what gets us the wildcard *.preview.sartiq.com certificate. All three run as a single shared compose project that the preview-host brings up at boot.

Per-preview stack

One compose project per preview, rendered to /data/preview/previews/<code>/. Six services per preview: backend, scheduler, celery_worker, compute_server (SERVICE_TYPE=api, healthcheck /health), compute_worker (SERVICE_TYPE=worker_workflow, mem_limit 2g, concurrency 2/2/2/1), and webapp. Each project joins two Docker networks: the shared preview-shared network (to reach Postgres, Redis, and Caddy) and its own preview-<code> bridge (for intra-preview comms). On the shared network, the backend and webapp containers get the aliases preview-<code>-backend and preview-<code>-webapp — that's what lets Caddy route to a specific preview without service-name collisions.


Per-preview identifiers

When you SSH onto the host, this is how you find a preview's files, DB, bucket, and image tags. All patterns are deterministic — derived from the canonical code by internal/previewnaming/naming.go in the cliq repo.

Resource Pattern Example (code=sar-23)
Postgres database (backend) preview_<underscored>_backend preview_sar_23_backend
Postgres database (compute) preview_<underscored>_compute preview_sar_23_compute
Postgres user preview_<underscored> preview_sar_23
R2 bucket shootify-media-preview-<code> shootify-media-preview-sar-23
Compose dir on host /data/preview/previews/<code>/ /data/preview/previews/sar-23/
Image tag prefix (for cleanup) preview-<code>- preview-sar-23-

Codes must be canonical

Codes are lowercase, [a-z0-9-], with a leading letter. Sanitize("SAR-23") == "sar-23". The CLI rejects non-canonical input client-side; the preview-host rejects it again with HTTP 400 server-side. This is the guard that stops two raw codes — say SAR-23 and sar+23 — from racing on the same DB, bucket, and image tags.


Lease vs auto-shutdown

These are the two timers people get confused about. They're separate.

Lease (per-preview)

The lease bounds how long an individual preview lives. Default 3h, max 24h. cliq preview extend <code> <duration> sets the new expiry to max(now + d, current_expiry + d), capped at the max — it is not additive (extending by 1h four times in a row does not give you 4h past the original expiry). When the lease elapses, the host reaps the preview: containers down, DB dropped, bucket deleted, image tags cleaned up.

Auto-shutdown (host-wide)

The shared VM self-stops via the GCP Compute API after IdleRequired (5 minutes) of all six veto gates clearing simultaneously:

  1. state_active — any preview not in Running
  2. containers — any running preview-labelled Docker container
  3. locks — any held operation lock
  4. hold — any preview with an active hold (HoldUntil > now)
  5. ssh_pty — any active SSH session on the host (probed via /run/utmp mounted into the host container)
  6. kill_switch — file /data/preview/no-autooff present

cliq preview hold <code> <duration> blocks gate hold for the duration. hold does not extend the lease — use extend for that.

Probes are fail-closed

If containerCount or sshCount errors out, the reaper emits a synthetic probe_error_<name> veto rather than letting the VM stop on bad data. A flaky probe keeps the host up, never the other way around.

If /etc/preview/autooff-enabled is absent on the VM, the reaper logs what it would have done instead of actually stopping. That sentinel is how you put the host into dry-run mode without redeploying.


Image flow

Code becomes a running preview by way of GHCR:

  • The dev runs cliq preview create <code>. By default the host orchestrates the three image builds on GitHub Actions at the resolved refs and pushes them to GHCR. With --local-build, the laptop checks out the three repos at their resolved refs, builds, and pushes instead.
  • Image ref pattern: ghcr.io/shootify-io/<dev-package>:preview-<code>-<sha>. The three dev packages are shootify-backend/backend, shootify-webapp, and shootify-compute-server. Concrete example for the backend: ghcr.io/shootify-io/shootify-backend/backend:preview-sar-23-abc123def.
  • Preview tags live ON the existing dev packages (not on per-preview packages) so docker push deduplicates layers against the existing :dev tag — only the diff layers actually upload.
  • The host runs docker compose pull against the rendered per-preview compose file, then up. It authenticates to GHCR using its own GHCR_READ_USER / GHCR_READ_TOKEN — a read-only PAT, distinct from the dev's write PAT.

Builds run on GitHub Actions by default

cliq preview create and cliq preview rebuild orchestrate the image builds on GitHub Actions — faster push, and layers dedup against dev's buildcache. Pass --local-build to build + push from your own machine instead (the legacy path); on a slow uplink that's noticeably slower and a laptop reboot will interrupt it.


Seeding

Previews seed from the same R2 dataset as cliq seed pullsartiq-misc/seed-data/ (dump.sql.zst plus an assets/ tree). The difference is where the I/O happens: the host runs the restore, not your laptop.

  • DB restore. The host pipes the dump into psql inside the shared Postgres container, scoped to the preview's user and the preview_<underscored>_backend database. The restore is prefixed with DROP SCHEMA public CASCADE, but the restorer first asserts that the target DB and user are non-empty (fail-closed against ever DROP'ing the wrong database).
  • Asset copy. Server-side R2 CopyObject from sartiq-misc/seed-data/assets/ into shootify-media-preview-<code>. Both buckets live in the same Cloudflare account, so no bytes pass through the host.

cliq preview create --no-seed skips both steps. cliq preview seed <code> re-runs the restore on an existing preview (brief container stop while the DB is dropped, then resume).

Schema-divergent branches need a regenerated seed

The seed restores against your branch's schema — the backend container runs alembic on startup, just like in local dev. If your branch has a not-yet-merged migration that the canonical dump doesn't know about, the seed may fail. The same cliq seed verify check that cliq seed push enforces applies before you publish a dump.

See Storage → Local-Dev Seed Dataset for the dataset itself.


Authentication

Two sides, two credential sets.

What the developer needs

A Cloudflare User API Token (profile-scoped from dash.cloudflare.com/profile/api-tokens, so it auto-invalidates when you leave the org — not an Account API Token) with:

  • Account / Workers R2 Storage / Edit — mandatory; the laptop never persists long-lived S3 keys, it exchanges this token for short-lived R2 creds at use-time
  • User / User Details / Read — optional, lets cliq preview doctor attribute previews to your email
  • Zone / DNS / Edit — required for cliq preview (matches the host's Caddy DNS-01 scope)

A GitHub classic PAT (fine-grained PATs are rejected by GHCR) with:

  • repocliq preview wake dispatches a workflow on a private repo
  • write:packages, read:packages, delete:packages — push, pull, and clean up preview image tags

Both live in ~/.config/sartiq/cliq/credentials.yaml at mode 0600. See cliq CLI for the login walk-through.

What the host needs

Listed in /etc/preview/secrets.env on the VM and read at startup by the preview-host binary. The high-impact entries:

  • GHCR_READ_USER / GHCR_READ_TOKEN — host's pull-only PAT, separate from any developer's write PAT
  • CLOUDFLARE_API_TOKEN — used by the host AND passed to Caddy for DNS-01 wildcard ACME; needs Zone / DNS / Edit and Workers R2 Storage / Edit
  • CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ZONE_ID — both required, host startup fails without them
  • App keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, FAL_KEY, FASHN_API_KEY, HF_TOKEN, FREEPIK_API_KEY, SENTRY_DSN, GCP_PROJECT) — passed through to the per-preview compute containers
  • Postgres + Redis admin creds — for the shared services

Rotate the host's read PAT independently from any dev's write PAT — rotating one doesn't affect the other.


Operations

Cross-references for the operator landing on this page at 2am:

  • VM details. Infrastructure → Servers → GCPpreview-host row. Provider/region: GCP sartiq-staging, europe-west8-b. Machine: n2-standard-8 (8 vCPU / 32 GB RAM). Service account: preview-host@sartiq-staging. Network tags: ssh, http, preview.
  • DNS. Infrastructure → Networking — the *.preview wildcard A record is terraform-managed in shootify-deployment/terraform/environments/dns/main.tf. If you need to change DNS, plan + apply through terragrunt; don't touch the Cloudflare UI.
  • R2 layout. Infrastructure → Storage — per-preview buckets, lifecycle policies, custom-domain attach for the media hostname.
  • Caddy. Reverse Proxy. Note: the preview-host runs a custom Caddy image (ghcr.io/shootify-io/preview-caddy) with the caddy-dns/cloudflare module compiled in. The dev / staging / prod Caddy is the stock caddy:2-alpine — different image, different feature set. The preview Caddy uses DNS-01 for the wildcard cert; the others use HTTP-01.

What's not in scope

Be explicit about non-goals so people don't ask:

  • No GPU on the preview host. LLM, FAL, and U2NET-CPU workers only. No local GPU inference.
  • No rolling deploy for the preview-host control-plane image itself. Bumping its tag requires recreating the host container, which stops every preview running on the VM.
  • No per-preview Caddy. A single shared Caddy fronts every preview with a single wildcard cert. All previews share the same TLS material.
  • Previews are not backed up. They are ephemeral by design and out of scope for the staging-backup sidecar.