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¶
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:
Services are backend, scheduler, celery_worker, compute_server,
compute_worker, webapp.
3. Iterate¶
Push new code to your branch, then:
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¶
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:
state_active— any preview not inRunningcontainers— any running preview-labelled Docker containerlocks— any held operation lockhold— any preview with an active hold (HoldUntil > now)ssh_pty— any active SSH session on the host (probed via/run/utmpmounted into the host container)kill_switch— file/data/preview/no-autooffpresent
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 areshootify-backend/backend,shootify-webapp, andshootify-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 pushdeduplicates layers against the existing:devtag — only the diff layers actually upload. - The host runs
docker compose pullagainst the rendered per-preview compose file, thenup. It authenticates to GHCR using its ownGHCR_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 pull —
sartiq-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
psqlinside the shared Postgres container, scoped to the preview's user and thepreview_<underscored>_backenddatabase. The restore is prefixed withDROP 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
CopyObjectfromsartiq-misc/seed-data/assets/intoshootify-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-timeUser / User Details / Read— optional, letscliq preview doctorattribute previews to your emailZone / DNS / Edit— required forcliq preview(matches the host's Caddy DNS-01 scope)
A GitHub classic PAT (fine-grained PATs are rejected by GHCR) with:
repo—cliq preview wakedispatches a workflow on a private repowrite: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 PATCLOUDFLARE_API_TOKEN— used by the host AND passed to Caddy for DNS-01 wildcard ACME; needsZone / DNS / EditandWorkers R2 Storage / EditCLOUDFLARE_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 → GCP
—
preview-hostrow. Provider/region: GCPsartiq-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
*.previewwildcard A record is terraform-managed inshootify-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 thecaddy-dns/cloudflaremodule compiled in. The dev / staging / prod Caddy is the stockcaddy: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.
Related¶
- cliq CLI — full command reference
- Reverse Proxy — Caddy (stock vs. preview-caddy)
- Infrastructure → Networking — VPC, DNS, the
*.previewwildcard record - Infrastructure → Storage — R2 layout, seed dataset
- Infrastructure → Servers → GCP —
preview-hostVM details - The cliq repo README's
cliq previewsection — canonical flag-level reference