Deployment¶
This page is for whoever runs thinkingface: how to bring it up for evaluation, which database and storage backends exist and when to pick each one, what production on GCP looks like, and what happens (and does not happen) around upgrades and backups. See Configuration for the full environment variable reference.
The network boundary is your read boundary
thinkingface has no per-repository visibility setting. By default every repository on an
instance is readable, clonable and downloadable by anyone who can reach it, authenticated
or not — accounts and organization roles govern writes only. Deploy it where only its
intended audience can reach it, and treat network reachability as the access control it
actually is. TF_REQUIRE_AUTH_FOR_READ (below) closes
anonymous reads instance-wide, but every account that can sign in can still read every
repository.
Local and evaluation deployment (Docker Compose)¶
The repository ships a docker-compose.yml that brings up the whole stack: the web UI, the
API, PostgreSQL, and a local GCS emulator.
cp .env.example .env
docker compose up -d
or equivalently, make up. This starts four services:
| Service | Image / build | Role |
|---|---|---|
web |
built from frontend/ |
Next.js UI, served with next start on port 3000. Also forwards the browser's API calls to api (below) |
api |
built from backend/ |
The Go binary: HF-compatible REST API, git smart HTTP, LFS, the Parquet viewer, and the background sync worker, all in one process, on port 8080 |
postgres |
postgres:17-alpine |
Metadata database (repositories, users, tokens, jobs, experiment runs) |
gcs |
fsouza/fake-gcs-server |
A local emulator standing in for a real GCS bucket |
Once it is up:
- Web UI: http://localhost:3000
- API: http://localhost:8080 — for
tf,huggingface_hub,gitand the trackio shim; the browser only needs the web UI's port - Default login:
admin/admin(see the warning below)
These ports are published on 127.0.0.1 only, so they are reachable from the machine running
Docker and nowhere else — see Network access for why, and for how to open them
to your LAN or reach a remote machine over SSH.
Data persistence¶
Each stateful service writes to a named Docker volume:
| Volume | Holds |
|---|---|
pg-data |
PostgreSQL's data directory |
gcs-data |
The fake-gcs-server's backing filesystem (LFS objects, blobs) |
git-data |
Bare git repositories under GIT_ROOT (/data), plus the generated SSH host key |
sqlite-data |
The SQLite database file (only used in SQLite mode) |
These volumes survive a container restart or docker compose down.
Stopping and resetting¶
docker compose down # stop and remove containers; volumes are kept
make clean # down -v on both stacks (default and SQLite) -- also removes the named volumes
docker compose down (or make down) leaves your data in place, so docker compose up -d
afterwards picks up exactly where you left off. To start over from nothing — a fresh
database, an empty bucket, no repositories — run make clean (or docker compose down -v
directly), which deletes the named volumes along with the containers. make clean covers
the SQLite-mode stack too (sqlite-data), which a bare docker compose down -v leaves
behind.
Change the defaults before exposing this to anyone else
A default docker compose up seeds an admin account with the well-known password
admin, and signs session cookies with a publicly known development secret. This is
fine on a laptop reachable only by you. Before putting the instance on a network anyone
else can reach, set TF_ADMIN_PASSWORD and TF_SESSION_SECRET in .env — see
Configuration for both. The server also refuses to start on these
defaults at all once TF_PUBLIC_URL is anything other than localhost/127.0.0.1 (or
a .localhost name) — including a plain http:// instance on an internal hostname or
IP, not just https:// ones.
Network access¶
Published ports are loopback-only by default¶
docker-compose.yml publishes the web (3000), api (8080) and git-over-SSH (2222) ports on
127.0.0.1. Docker's published ports bypass the host's firewall: Docker writes its own
packet-filter rules ahead of ufw, firewalld and friends, so a port published on every
interface is reachable from the whole network no matter what the host firewall says. Binding to
loopback is what actually keeps a fresh stack — with its well-known admin / admin login —
private to the machine it runs on.
To make the stack reachable from other machines on your network, set TF_BIND_ADDR in .env
and recreate the containers:
echo 'TF_BIND_ADDR=0.0.0.0' >> .env # or one specific interface address
docker compose up -d
Before you do, set TF_ADMIN_PASSWORD and TF_SESSION_SECRET, and point TF_PUBLIC_URL at the
address other machines will use (the server refuses to start on the default secrets once it is
not loopback — see the warning above).
postgres (5432) and the gcs emulator (4443) are always published on 127.0.0.1, whatever
TF_BIND_ADDR says: the emulator has no authentication of its own, and only host-side tooling
(make test-store-pg, the E2E suite's bucket checks) ever needs to reach either one directly. The
containers reach each other over the Compose network regardless.
How the web UI reaches the API¶
The browser talks to the web UI's origin only. Every API call it makes — under /api/, plus file
downloads — goes to the Next.js server, which forwards it to the API at API_URL
(http://api:8080 under Compose), streaming uploads and downloads in both directions. So:
- The browser only needs the web UI's port. The API's port is for the non-browser clients:
the
tfCLI, the trackio shim,huggingface_hub,gitandgit-lfs. API_URLis read when the container starts, not baked into the image: the samewebimage works wherever the API lives, and changing it needs a restart, not a rebuild.TF_ALLOWED_ORIGINSneeds no entry for the web UI, because the browser never calls the API cross-origin. The proxy makes the equivalent cross-site-request check itself, against its own origin, before forwarding a state-changing request.- Clone URLs and the
HF_ENDPOINTin the usage snippets still come fromTF_PUBLIC_URL: those are forgitandhuggingface_hub, which talk to the API directly, soTF_PUBLIC_URLmust still be the address they reach the API on. In emulator mode (STORAGE_DRIVER=gcs-emulator) the same URL is embedded in Git LFS transfer links, so an LFS upload or download from a machine that cannot reachTF_PUBLIC_URLfails.
This is the default: it applies when the web image is built with NEXT_PUBLIC_API_URL empty,
which is what Compose and frontend/Dockerfile do unless you set it. Setting
NEXT_PUBLIC_API_URL keeps the older cross-origin behaviour: the browser calls that URL directly,
so it must be reachable from the browser, it is compiled into the image (rebuild with
docker compose up -d --build web after changing it), and the web UI's origin must be listed in
TF_ALLOWED_ORIGINS.
Client addresses behind the proxy
Behind the proxy, every browser request reaches the API from the web container, while the
API rate-limits failed password attempts per client address (10 a minute by default,
TF_AUTH_RATE_LIMIT_PER_MIN). So that one visitor failing to sign in cannot throttle every
other browser, the web container's server reports each browser's own socket address to the
API in a dedicated header, and the API believes that header only from the web tier:
- from a connection whose peer is listed in
TF_TRUSTED_WEB_PROXIESon the API. The compose file sets it toweb, the web container's name, so this works out of the box; a direct caller of the API port arrives from somewhere else and cannot forge the header; - or from a request carrying
TF_WEB_PROXY_SECRET, set to the same value (32 bytes or more,openssl rand -hex 32) on both the API and the web container. Use this when the web UI reaches the API through a load balancer, where the peer says nothing; the Terraform deployment generates one.
If reverse proxies that append to X-Forwarded-For sit in front of the web UI, set
TF_WEB_TRUSTED_PROXY_HOPS on the web container to how many there are, and it reads the
browser's address that many entries from the right; otherwise leave it at 0 and whatever
a browser writes into X-Forwarded-For is ignored. TF_TRUST_PROXY_IPS is a different,
older switch for proxies in front of the API: it trusts X-Forwarded-For from every
connection, so only enable it when the API port is not reachable by untrusted clients.
With neither TF_TRUSTED_WEB_PROXIES nor TF_WEB_PROXY_SECRET configured — or with the web
UI run through next start / next dev instead of its production server — every browser
shares one address's budget again, and anyone who can reach the web UI can push every browser
sign-in to 429 by failing their own repeatedly. Existing sessions, personal access tokens,
git and SSH never go through this limit. huggingface_hub, git and tf talk to the API
directly and are identified by their own address.
A private deployment over an SSH tunnel¶
A common way to run thinkingface for yourself or a small team is on a VM with no public IP at all,
reached over SSH (directly, through a bastion, or with gcloud compute ssh --tunnel-through-iap).
Because the ports are loopback-only, nothing is exposed; the tunnel is the only way in.
On the VM:
cp .env.example .env
# The API as your workstation will reach it through the tunnel below: it is
# embedded in clone URLs, HF_ENDPOINT snippets and (with the emulator) LFS links.
echo 'TF_PUBLIC_URL=http://localhost:18080' >> .env
docker compose up -d
On your workstation:
ssh -N -L 13000:127.0.0.1:3000 -L 18080:127.0.0.1:8080 my-vm
- The web UI is at http://localhost:13000. The browser needs only this forward.
- The second forward is for everything else — the
tfCLI, the trackio shim,huggingface_hub,git:
tf login http://localhost:18080
export THINKINGFACE_ENDPOINT=http://localhost:18080 # trackio shim
export HF_ENDPOINT=http://localhost:18080 HF_HUB_DISABLE_XET=1
- Forwarding the same port numbers instead (
-L 3000:127.0.0.1:3000 -L 8080:127.0.0.1:8080) works with the defaultTF_PUBLIC_URL=http://localhost:8080, if those ports are free on your workstation. TF_PUBLIC_URLis a loopback address here, so the server does not insist on non-default secrets. SetTF_ADMIN_PASSWORDandTF_SESSION_SECRETanyway if anyone else has a shell on the VM or shares the tunnel.- For git over SSH, add
-L 12222:127.0.0.1:2222and setTF_SSH_PUBLIC_PORT=12222so the clone URL the UI shows matches.
A training machine elsewhere can reach the same API the same way — its own ssh -L to the VM — or,
if it cannot reach the VM at all, record runs offline and sync them later
(Offline runs).
Require sign-in for reads¶
By default anyone who can reach an instance can read every repository and every run without signing in. Where the network boundary alone is not enough — an instance shared by several teams behind one VPN, or one reachable over the internet — set:
TF_REQUIRE_AUTH_FOR_READ=true
TF_ALLOW_SIGNUP=false
Every request must then carry an identity — a session cookie, an access token, or HTTP Basic
credentials — or it is answered 401 with the error type authentication_required, reads and
writes alike. A handful of routes stay open, because they are what it takes to find out that
sign-in is required and to sign in: /healthz, sign-in, sign-up and sign-out,
/api/v1/me and /api/v1/server-info. /api/openapi.json is not among them.
How each client behaves:
- Web UI: a signed-out visitor is redirected to
/login, and back to the page they asked for after signing in. git/git-lfs: the401carries aWWW-Authenticatechallenge, so git prompts for credentials (or asks your credential helper) and retries — use an access token as the password. Git LFS transfer links the server has already signed keep working. Git over SSH is unaffected; it always required a registered key.huggingface_hub/datasets: setHF_TOKEN, as for writes.tfand the trackio shim: need a token for reads too —tf login,THINKINGFACE_API_KEYorTHINKINGFACE_TOKEN.
An account that is suspended or still waiting for sign-up approval counts as anonymous here too.
Pair it with TF_ALLOW_SIGNUP=false — or with TF_SIGNUP_REQUIRE_APPROVAL /
TF_SIGNUP_EMAIL_DOMAINS — because otherwise anyone who can reach the sign-up form can create an
account and read everything anyway. Every signed-in account and every valid token, read-scoped or
repository-restricted, can still read every repository: this closes anonymous access, it does not
add per-repository visibility.
Provisioning a token without a browser¶
Automation — a CI pipeline, a training machine, an AI agent — needs an access token, and on a fresh
instance nobody may have opened the web UI yet. thinkingface admin token create mints one next to
the database, with the same rules as the web UI's token form; it prints the token and nothing else
on stdout:
docker compose exec -T api thinkingface admin token create admin \
--name ci --expires-in-days 30 --repo datasets/admin/trackio-metrics > ci-token.txt
chmod 600 ci-token.txt
-T matters: without it Docker allocates a terminal and mixes the command's stderr (what it created,
for whom, until when) into the file. --repo restricts the token to that repository, which must
already exist — create it first with tf up, huggingface_hub or the web UI. --output FILE
writes the token to a file instead (mode 0600, refused if it exists), but that file is created
inside the container, so under Compose redirecting stdout as above is simpler. See
Minting a token from the command line
for every flag, and Restricting a token to repositories
for what a restricted token can do.
Troubleshooting from the logs¶
The effective configuration line¶
thinkingface serve logs one line at startup with the configuration it actually resolved — after
.env, Compose's environment: block and the built-in defaults have all had their say:
docker compose logs api | grep 'effective configuration'
{"time":"...","level":"INFO","msg":"effective configuration","public_url":"http://localhost:8080",
"listen_addr":":8080","ssh_enabled":true,"ssh_addr":":2222","ssh_public_port":"",
"allowed_origins":["http://localhost:8080","http://localhost:3000","http://127.0.0.1:3000"],
"database_driver":"postgres","storage_driver":"gcs-emulator","storage_bucket":"thinkingface",
"storage_prefix":"","storage_emulator_host":"http://gcs:4443","wal_mode":"shadow","allow_signup":true,
"signup_require_approval":false,"signup_email_domains_count":0,"org_creation":"anyone",
"require_auth_for_read":false,"cookie_secure":"inferred","trust_proxy_ips":false,
"trusted_proxy_hops":1,"session_secret_default":true,"admin_password_default":true}
When a setting does not seem to take effect, this line tells you whether the server ever saw it.
Secrets are never logged: the database appears only as its driver, and session_secret_default /
admin_password_default only say whether those two are still the well-known development
defaults.
"cors: origin not allowed"¶
{"level":"WARN","msg":"cors: origin not allowed","origin":"http://10.0.0.5:3000",
"hint":"add it to TF_ALLOWED_ORIGINS, or use the web UI's same-origin /api proxy"}
A browser page on that origin called the API directly and was refused CORS headers, so the page
could not read the response. It is logged once per origin (up to 64 distinct origins). With the
default same-origin proxy the browser never calls the API cross-origin, so seeing this usually
means the web image was built with NEXT_PUBLIC_API_URL set: either add the web UI's origin
(scheme, host and port, exactly as in the browser's address bar) to TF_ALLOWED_ORIGINS, or
rebuild the web image with NEXT_PUBLIC_API_URL empty. An origin you do not recognise is some
other page probing the API, and needs nothing from you.
Choosing a database backend¶
thinkingface reads a single DATABASE_URL environment variable and dispatches on its
scheme:
postgres://orpostgresql://— PostgreSQLsqlite://— SQLite (pure Go, viamodernc.org/sqlite; no CGo)
A docker compose up (no override) always runs PostgreSQL, since docker-compose.yml
assembles DATABASE_URL from POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB and
passes it to the api service directly.
SQLite mode¶
To bring the whole stack up against SQLite instead — no postgres container at all — use:
make up-sqlite
which runs docker compose -f docker-compose.yml -f docker-compose.sqlite.yml up -d api web
gcs. The postgres service is excluded, and the api container's DATABASE_URL becomes
sqlite:///data/db/thinkingface.db, persisted in a sqlite-data volume.
SQLite mode is a reasonable choice for evaluation, a single-operator instance, or a small team, and it is what the production "SQLite + Litestream" option below is built on. It comes with hard limits that make it the wrong choice past that scale:
- A single process, a single writer connection. Concurrent writes from multiple replicas are not supported — there is no clustering or replication story at the application layer.
- No horizontal scaling. You cannot run more than one
apireplica pointed at the same SQLite file. - Search behaves differently from PostgreSQL: the HF-compatible
search=substring match becomes aLIKE, which is case-insensitive for ASCII only (no Unicode case folding), and the web UI's full-text search runs on SQLite's FTS5 (unicode61tokenizer) rather than PostgreSQL'stsvector, so ranking and stemming behavior are not identical between the two.
Do not choose SQLite mode if you need multiple api replicas, if your namespaces or search
content rely on non-ASCII case folding, or if you need full-text search that matches
PostgreSQL's behavior exactly.
PostgreSQL mode¶
PostgreSQL is the default for docker compose up and the right choice for anything beyond a
single-operator evaluation instance: it supports concurrent writers, standard row-level
locking, and (on Cloud SQL, in production) automated backups with point-in-time recovery. If
you are unsure which backend to pick, pick this one.
Object storage¶
Large files (Git LFS objects) and non-LFS blobs published after a push are stored in an
object store, not in the git repositories on disk. STORAGE_DRIVER selects the
implementation:
gcs-emulator— talks tofake-gcs-serveratSTORAGE_EMULATOR_HOST. This is whatdocker compose upuses locally. The emulator cannot verify signed URLs, so in this mode the server proxies object bytes itself rather than issuing them.gcs— talks to a real Google Cloud Storage bucket, named byGCS_BUCKET(optionally scoped under aGCS_PREFIX). In this mode the server issues short-lived signed URLs (TF_SIGNED_URL_TTL) and the client (browser,huggingface_hub,git-lfs) transfers directly against GCS.
Credentials and permissions for real GCS¶
The gcs driver uses the standard Google Cloud Go client, which resolves credentials the
usual way — Application Default Credentials, meaning a mounted service account key
(GOOGLE_APPLICATION_CREDENTIALS), gcloud auth application-default login locally, or (in
production) the workload's attached service account. There is no thinkingface-specific
credential variable.
The service account needs, at minimum:
roles/storage.objectAdmin, scoped to the target bucket (not project-wide)roles/iam.serviceAccountTokenCreatoron itself — this lets it sign URLs (signBlob) without a downloadable private key, which is what makes keyless signed URLs work on a workload-identity-backed service account
Object storage has three top-level prefixes: lfs/ (Git LFS objects), blobs/ (every other
file the sync worker publishes after a push), and, when the Continuity migration is enabled
(see below), wal/ (the git write-ahead log). All three are content-addressed, meaning
distinct pushes that produce the same file share storage.
The bucket needs CORS configured, or two browser features break¶
Two things in the Web UI fetch object bytes directly from your browser rather than through a
plain download link: the dataset viewer's SQL mode
(DuckDB-WASM downloads the whole Parquet file to query it locally) and the plain-file
preview's full-text fallback for CSV / JSON Lines files over 512 KB (see
Browsing the Web UI). Both go through the same resolve
endpoint, and with STORAGE_DRIVER=gcs that endpoint answers with a redirect to a short-lived
signed URL on storage.googleapis.com rather than streaming the bytes itself — a different
origin from both the web UI and the API. Unless the bucket is configured to answer a
cross-origin browser request for that origin with CORS headers, the browser refuses to read
the response, and both features fail — usually reported as a generic "network error" rather
than anything naming CORS or GCS, which makes the cause easy to miss.
This is specific to STORAGE_DRIVER=gcs. Under gcs-emulator (what docker compose up uses)
the API streams the bytes itself instead of redirecting, so the browser's request never
leaves the API's own origin and no bucket CORS policy is involved — which is also why this is
easy to not notice until you point a deployment at a real bucket for the first time.
If you provision the bucket with the Terraform in infra/, this is already handled: see
"The bucket needs CORS configured" under Production on GCP below.
If you provision the bucket yourself — by hand, or with different infrastructure tooling —
configure the same policy directly, for example:
cat > cors.json <<'EOF'
[
{
"origin": ["https://your-web-ui-origin.example.com"],
"method": ["GET", "HEAD"],
"responseHeader": ["Content-Type", "Content-Length", "Content-Range", "ETag"],
"maxAgeSeconds": 3600
}
]
EOF
gcloud storage buckets update gs://your-bucket --cors-file=cors.json
Use the exact origin your browser loads the web UI from (scheme, host, and port) — a bucket
CORS policy has no wildcard-subdomain form, so list every origin you actually serve the UI
from, and never "*": every object behind this bucket becomes reachable through a signed URL
the moment someone gets a browser to fetch one, so naming the origin explicitly is what keeps
that read scoped to your own deployment. GET/HEAD cover both features above; nothing in
the Web UI writes to the bucket directly from the browser even with STORAGE_DRIVER=gcs —
uploads always go through the API.
Production on GCP¶
The infra/ directory holds Terraform for a GCP production deployment. It provisions:
- A GCS bucket for
lfs/,blobs/, and (when applicable)wal/, with a CORS policy allowing the web UI's origin (see below) - An Artifact Registry repository for the backend and frontend images
- A
google_cloud_run_v2_servicefor the API (gen2,h2c,min_instance_count = 1, CPU always allocated, Direct VPC egress to reach the database) - A
google_cloud_run_v2_servicefor the web frontend - A service account for the API workload, scoped to the bucket and to the secrets it needs
- Optionally, Cloud SQL for PostgreSQL 17 (private IP only, automated backups with point-in-time recovery)
Bring it up with:
cd infra
terraform init # add -backend-config=... once you configure a real backend
terraform plan -var="project_id=my-gcp-project"
terraform apply -var="project_id=my-gcp-project"
Terraform provisions the infrastructure but deliberately ignores drift on the container
image field afterwards, so pushing new images and pointing the Cloud Run service and job at
them is a separate step (gcloud run deploy / gcloud run jobs update). This first apply
alone is not enough to reach a working instance — two things still need doing, both
covered in full in infra/README.md's "After apply" walkthrough:
- The api's public URL is a placeholder (
https://api.{environment}.example.com) until you set it. LFS href generation and the HF-compatible resolve redirects are wrong against the default. Deployapi, read its real URL back withterraform output -raw api_url(or point a custom domain at it), pass that as-var="api_public_url=...", and re-apply. The CORS allow-list is a separate variable (TF_ALLOWED_ORIGINS, derived fromweb_public_url— seeinfra/README.md) that already defaults to thewebservice's own*.run.appURL once it exists, so it works out of the box without this step; setweb_public_urlexplicitly only once you put a custom domain in front ofweb. infra/README.mdbuilds the web frontend image after you know the api's URL, withdocker build --build-arg NEXT_PUBLIC_API_URL=$(terraform output -raw api_url) .... That is the cross-origin mode described under How the web UI reaches the API: the browser calls the api's*.run.appURL directly, the value is compiled into the browser bundle atdocker buildtime rather than read at startup, and the web UI's origin must be inTF_ALLOWED_ORIGINS(which the Terraform derives, as described above). Building without the argument produces the same-origin image instead, whose server forwards the browser's API calls to theAPI_URLthe Terraform already sets on thewebservice — mind the client-address note in that section if you go that way.
Not provisioned by this Terraform: a custom domain or TLS front end. Cloud Run terminates
TLS itself and serves each service on its own *.run.app URL, which is enough to get
started; add a domain mapping or a load balancer once you have decided on a domain strategy.
The bucket needs CORS configured¶
See "The bucket needs CORS configured, or two browser features break" above
for what this is for. infra/'s bucket resource already carries the right policy, derived
from the same value TF_ALLOWED_ORIGINS is: web_public_url if you set it, otherwise the
web Cloud Run service's own *.run.app URL — the same single source of truth
TF_ALLOWED_ORIGINS uses, so the two allow-lists cannot drift apart. Like
TF_ALLOWED_ORIGINS, this resolves to a real value from the first apply onward (web's
*.run.app URL is deterministic from its name/region/project, unlike api_public_url, which
falls back to a placeholder and does need the manual re-apply described above) — no extra
step needed unless you later put a custom domain in front of web, at which point set
web_public_url and re-apply so both allow-lists follow it. The policy's cache lifetime is
var.bucket_cors_max_age_seconds (default 1 hour, see its description in
infra/variables.tf).
Database on GCP: Cloud SQL vs. SQLite + Litestream¶
Terraform's database_backend variable (postgres, the default, or sqlite) switches how
the API and its scheduled maintenance job persist metadata:
postgres— a Cloud SQL for PostgreSQL 17 instance, private IP only, reached from Cloud Run over Direct VPC egress.DATABASE_URLis assembled and stored in Secret Manager. This is the configuration to pick when you need strict consistency across concurrent API instances.sqlite— no Cloud SQL instance is created at all.DATABASE_URLbecomes a plain (non-secret)sqlite:///data/db/thinkingface.dbpointing at the container's ephemeral filesystem, andTF_LITESTREAM_REPLICA_URLis set to ags://path. The container's entrypoint (backend/entrypoint.sh) uses Litestream to restore that file from GCS on startup and continuously replicate writes back to it while the server runs, using the workload's own credentials — no extra key needed. Because SQLite assumes a single writer, the Cloud Run service'smax_instancesis forced to1in this mode regardless of the configured maximum.
This avoids running Cloud SQL at all, which is attractive for a small-scale deployment,
but it has a real caveat: a Cloud Run revision rollout can briefly run the old and new
revision side by side, and in sqlite mode that means two writers to the same GCS replica
for a short window during every deploy. Litestream does not reconcile multiple writers, so
writes that land on the outgoing revision during that window can be lost. Mitigate this by
deploying with --no-traffic and a manual traffic cutover, or accept the small window if
deploys are infrequent. If you need strict consistency, use the Cloud SQL configuration
instead.
There is no garbage collection in this mode, ever. thinkingface gc reclaims orphaned
lfs//blobs/ objects (deleted repositories, superseded files) by reading the database's
reference counts, and it needs to see the same live data the serving process does. Under
sqlite that isn't possible — gc would only ever see a Litestream-restored snapshot,
and could delete objects uploaded after that snapshot was taken while still live and
referenced. backend/entrypoint.sh refuses to run gc at all in this mode (it exits
immediately rather than risk that), and the Terraform sqlite configuration does not even
create the scheduled gc Cloud Run Job in the first place. In practice this means lfs/,
blobs/ and tmp/uploads/ in the bucket only ever grow for the life of a sqlite
deployment — factor that into your storage cost expectations, and pick the postgres
configuration instead if reclaiming storage from deleted/superseded content matters to you.
The Continuity / WAL migration¶
Recent Cloud Run compatibility for git itself rests on a design called the Continuity
migration (TF_WAL_MODE in Configuration): rather than requiring
persistent disk for bare git repositories, pushes are additionally written to a
generation-based write-ahead log in the GCS bucket (wal/), which can demote local disk to
a rebuildable warm cache. docker-compose.yml runs the API in shadow mode by default
(pushes are mirrored into the WAL best-effort, disk stays authoritative), and Terraform's
Cloud Run configuration depends on this migration to run without a persistent volume at all.
A daily Cloud Run Job (compact), triggered by Cloud Scheduler, performs WAL compaction.
Upgrades and database migrations¶
Every thinkingface invocation that touches the database — including serve itself —
applies any pending SQL migration automatically on startup, before doing anything else.
Migrations are tracked by file name in a schema_migrations table, applying each one
exactly once and in order; re-running an already-applied migration is a no-op. This means a
plain image upgrade and restart (docker compose up -d after pulling a new image, or a
Cloud Run deploy) carries out the database migration as part of coming up — there is no
separate migration step to run by hand for the common case.
If you would rather apply migrations ahead of a rollout (for example, to keep a maintenance window short), the same binary supports it directly:
docker compose run --rm api migrate
This applies pending migrations and exits without starting the server.
Backup and restore¶
What the repository actually gives you differs by backend:
- PostgreSQL on Cloud SQL: automated daily backups with point-in-time recovery, provided
by Cloud SQL itself and enabled by the Terraform configuration in
infra/. - SQLite + Litestream on Cloud Run: continuous replication of the SQLite file to GCS.
Restoring means running
litestream restore(the same command the container's entrypoint runs on startup) against the replica URL; there is no separate thinkingface-level restore command. - Object storage (
lfs/,blobs/, andwal/when Continuity is enabled): the Terraform configuration turns on bucket versioning, so old generations of the WAL's index and any overwritten object stay recoverable unless explicitly deleted. Deletion of orphaned LFS objects and blobs is handled by reference-counted garbage collection (thinkingface gc, with a--dry-rundefault), not by an age-based lifecycle rule, so nothing in the bucket disappears on its own. Inpostgresmode the Terraform configuration schedules this for you: a weekly Cloud Run Job (gc), triggered by Cloud Scheduler, offset from thecompactschedule so the two never run at the same time. Insqlitemode there is no gc Job — it would read a Litestream-restored snapshot rather than the live database and could delete objects uploaded since that snapshot, so it is not created and the entrypoint refuses it; storage is not reclaimed automatically there. It only reports orphaned objects until you opt in — setgc_delete_enabled = true(Terraform variable) once you've reviewed a few dry-run reports and are confident they agree with what your deployment actually still references; seeinfra/README.mdfor the full rationale and how to trigger a supervised one-off deletion pass instead of waiting for the default weekly schedule. - Local Docker Compose deployment: there is no backup story at all. Data lives in the
pg-data/sqlite-data,gcs-data, andgit-datanamed volumes, and nothing in the repository snapshots or ships them anywhere. If you need backups for a Compose-based deployment, you are responsible for backing up those Docker volumes yourself (for example, with a periodicdocker run --rm -v pg-data:/data ... tarjob); the repository does not provide one.
Bare git repositories on disk (or, once Continuity is enabled, the WAL) are the source of truth for repository content outside of PostgreSQL/SQLite; back them up according to whichever of the above applies to your deployment.
See also¶
- Configuration for every environment variable, including the ones
referenced above (
TF_ADMIN_PASSWORD,TF_SESSION_SECRET,DATABASE_URL,STORAGE_DRIVER,TF_WAL_MODE, and the rest). - Authentication for access tokens and SSH keys, once the instance is up.