Deploying Tabularia on Kubernetes
Operational notes for whoever moves Tabularia from infrastructure/docker-compose.yml
(the development stack) to Kubernetes. The compose file is the reference for which
services exist and how they talk to each other; it is not a production manifest:
it runs dev servers with hot reload and bind-mounts the sources.
Everything below marked verified was exercised on 2026-09-12 against the real stack (Scaleway object storage, managed ClickHouse).
Helm chart
A chart that applies everything on this page lives in
infrastructure/helm/tabularia. It encodes
the constraints as templates — beat cannot be scaled, the root
filesystem is read-only, /tmp is sized per role — and refuses to render when a
required secret is missing. Its
README explains each decision,
and values-production.example.yaml is a filled-in starting point.
This page remains the reference for why; the chart is how.
To try Tabularia on one computer first, without knowing Kubernetes, follow Try Tabularia on your computer with minikube. To try the chart as a company would run it (several nodes, enforced network policies, a private registry with its own certificate authority), follow A company-like cluster on one computer.
Components
| Component | Image | Command | Exposure | Replicas |
|---|---|---|---|---|
| Frontend (Nuxt 3) | frontend/Dockerfile.prod, see Frontend | image default | public | N |
| Gateway (FastAPI control plane) | gateway/Dockerfile, see Gateway image | image default, APP__ROLE=api | public | N |
| Orchestrator (the gateway's background work) | gateway/Dockerfile | image default, APP__ROLE=orchestrator | none | 1, or more for failover |
| Engine API | backend/Dockerfile, see Backend image | image default | internal only | N |
| Run worker | backend/Dockerfile | celery -A app.tasks.celery_app worker -E -Q celery --loglevel=info | none | N |
| Preview worker | backend/Dockerfile | celery -A app.tasks.celery_app worker -E -Q preview --loglevel=info | none | N |
| Celery beat | backend/Dockerfile | celery -A app.tasks.celery_app beat --schedule /tmp/celerybeat-schedule --loglevel=info | none | exactly 1 |
| PostgreSQL | managed service recommended | — | internal | — |
| Valkey / Redis | any Redis-compatible | — | internal | 1 |
| Object storage | any S3-compatible endpoint | — | — | — |
| ClickHouse (optional engine) | managed service | — | — | — |
Use the image default command for the gateway and the engine API. The compose file
overrides them with --reload, which must not reach production.
- Gateway: two workloads from one image, told apart by
APP__ROLE. Withapia process only answers requests, and can be replicated freely: one process uses one core (about 95 requests a second), and sessions, the sign-in throttle and the editor's presence notice live in Postgres. Withorchestratorit only does the background work — schedules, flow executions, reconciliation, clean-up — and is not exposed. Several orchestrators share the work through the database: a schedule slot and a queued execution are each taken by exactly one, and a unique index keeps a flow from having two executions running.all(the default, one process doing both) is what the development compose file runs. - Schema. Migrations (Alembic) are a step of their own:
python -m app.db.migra, run by an init container in the Helm chart before the application starts, under a Postgres advisory lock. The application never migrates in production; it checks that the database is not behind its code and refuses to start if it is (a newer database is fine: that is the previous version during a rolling update). It seeds the admin user. See database-migrations.md; upgrading an installation, step by step: upgrading.md. - Database connections. Each gateway and orchestrator process keeps its own pool,
up to
APP__GATEWAY_THREADS× 1.25 +DB__MAX_OVERFLOW(25 with twelve threads): size Postgres'max_connectionsfor the sum. - Beat: one replica. Two beats enqueue every periodic task twice.
- Postgres holds all control-plane metadata (users, groups, permissions, versioned flows, encrypted connection credentials, runs, audit log): back it up.
- Valkey is the Celery broker and the step-cache index. Its content is disposable: losing it drops queued tasks and makes the cache start cold, nothing else.
- One installation per Valkey database. The queues and keys have fixed names. Two
installations on the same Valkey and the same
externalServices.redis.db(staging and production on one managed Redis, both on db 0) share them: one's workers run the other's tasks, with the wrong key and storage, and Administration → Settings and → Workers mix the two. Give each its own db number, or its own instance. - One release per namespace. The namespaces of the users' code are named after the
release's namespace, and the Jobs, Secrets and images made at run time after run ids:
two releases in one namespace collide (the second install fails on the namespaces; with
createNamespaces: falsethey share them, and follow each other's Jobs).
Networking
- Public: the frontend and the gateway, on one host:
/to the frontend,/apito the gateway (not stripped; the gateway knows its prefix throughAPP__ROOT_PATH). The browser calls the API relatively (NUXT_PUBLIC_API_BASE=/api). Routing the gateway's own paths instead cannot work: they are also pages of the frontend. - The notebooks' host (with scripts): a second host name,
/apito the gateway alone, where the marimo notebooks' apps and editors are served (scripts.notebooks.host,NOTEBOOKS__ORIGIN): there the gateway answers only for them, so a notebook's code cannot use the session of whoever opens it. A sibling of the interface's host, with its certificate. See An address of their own. - Internal: the engine API is reached only by the gateway, through
ENGINE__BASE_URL(cluster DNS, e.g.http://engine:8000). Keep it off the ingress: it answers only requests carrying the gateway's internal token, derived fromSECURITY__FERNET_KEY(nothing to configure), and a NetworkPolicy should admit only the gateway, the orchestrator and the workers — the chart ships one, on by default. - The client's address: the gateway reads
X-Forwarded-Forfrom the right, past the proxies it trusts (--forwarded-allow-ips, the chart'sgateway.trustedProxies). Without it every client seems to be the ingress controller, and the sign-in throttle, keyed by (address, email), lets anyone lock the administrator out. The default trusts every private range, so any pod of the cluster could choose its address: narrow it to the pod CIDR of your ingress controller. The application never reads the header itself: the audit log, the sessions and the throttle all use the address the server resolved.
Ingress settings the application depends on:
| Setting | Why | Value |
|---|---|---|
| Max request body size | file uploads stream through the gateway to the engine; the gateway refuses one over UPLOAD__MAX_BYTES (1 GiB by default) with a 413 | 0 (unlimited) or above UPLOAD__MAX_BYTES |
| Request buffering | the proxy must not spool uploads to its own disk | off |
| Read timeout | previews wait for a worker synchronously (PREVIEW_TIMEOUT_SECONDS on the engine API, default 120) | above that ceiling |
Rate limit on /auth/login | optional: the gateway already slows repeated failures per (address, email) and per address, across replicas; an edge limit also stops a distributed attack | per client IP, on that path only |
Egress. The engine connects to databases that users configure as connections: that
is the product, and also a server-side request surface. Restrict the engine pods' egress
with a NetworkPolicy to the object storage, ClickHouse, Valkey and the approved
database hosts, and block cloud metadata endpoints such as 169.254.169.254. The chart
does it from networkPolicy.egressCIDRs (entries are CIDRs or {cidr, except}), and
always carves the metadata address out of a range that holds it, 0.0.0.0/0 included.
With the list empty no egress policy is rendered.
Administration → Cluster (clusterView.enabled, on by default) shows the pods of
the release and of the users' code, their state, CPU and memory, and the warning events,
read with read-only rights in those namespaces (no logs, no exec); CPU and memory need
metrics-server.
Users' code (scripts.enabled, containers.enabled) runs as Jobs in namespaces of
its own, never in the release's. The gateway (…-scaler) and the orchestrator
(…-orchestrator) are the only pods with a Kubernetes API token, and only the
orchestrator may create the image builds, in their privileged namespace. Those
namespaces get a NetworkPolicy: the internet and the other namespaces, not the pods of
the release's namespace, the private ranges (unless listed in
networkPolicy.userCodePrivateCIDRs) nor the cloud metadata. It needs a network plugin
that enforces egress rules. See Python and R in flows
and The Container node.
Backend image (backend/Dockerfile)
One Dockerfile for the four backend roles and for both development and production.
The local docker-compose.yml builds the very same file with WITH_CHDB=true and
WITH_DEV=true (test dependencies); production builds it with the defaults.
Production variants:
| Variant | Build | Size | Engines |
|---|---|---|---|
| Lightweight, the default | docker build backend/ | 727 MB | Polars, DuckDB, ClickHouse external |
| With chDB | add --build-arg WITH_CHDB=true | 1.36 GB | the above plus embedded chDB |
| With the SQL Server driver | add --build-arg WITH_MSSQL=true | +35 MB | Microsoft's driver, under Microsoft's licence: never published, see sqlserver.md |
chDB alone accounts for almost 600 MB, pandas and numpy included, which nothing else in the application needs. In production the ClickHouse dialect is covered by the external ClickHouse engine, so the lightweight variant is the default. Without chDB the engine picker shows it as not configured, and a flow that still asks for it fails with a clear message. For reference, the development image is 1.41 GB.
Verified with --read-only and a tmpfs /tmp: engine API healthy, run worker ready,
beat started; every available engine producing identical results on real data; CSV and
Excel exports written to /tmp; database ingest from PostgreSQL and ClickHouse working in
the lightweight variant.
- runs as uid/gid 10001, no shell login, no build tools, no
uv, no test dependencies; - compatible with a read-only root filesystem: the only writable path is
/tmp(TMPDIR=/tmp,HOME=/tmp); uploads, intermediate parquet files, DuckDB and Polars spills and chDB state all go there; MALLOC_ARENA_MAX=2is preset to limit memory fragmentation from Polars/Arrow threads.
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: { sizeLimit: 50Gi } # size per role, see below
Sizing /tmp
Without a sized /tmp volume, a large file lands in the container's ephemeral storage
and the kubelet evicts the pod.
| Role | What lands in /tmp | Size for |
|---|---|---|
| Engine API | uploaded files and their parquet conversion, CSV/Excel exports | largest upload or export |
| Run worker | source parquet for in-process engines, database refresh results, data written to database or S3 outputs | largest dataset |
| Preview worker | source parquet for in-process engines | largest source |
| Beat | its schedule file | a few KB |
With the ClickHouse engine in s3 transport for a flow, in both its development and
production engine, flow execution never touches local disk: the ClickHouse server reads
sources from and writes results to the bucket. Uploads, exports, database refreshes and
database/S3 outputs still use /tmp whatever the engine.
Gateway image (gateway/Dockerfile)
Same rules as the backend image: one Dockerfile for development and production, uid/gid
10001, compatible with a read-only root filesystem, /tmp the only writable path.
The gateway itself writes no files, so a small emptyDir on /tmp is enough. Test
dependencies are included only with --build-arg WITH_DEV=true, which the local compose
sets. Production size: 199 MB.
Verified read-only against an empty PostgreSQL: the migration step creates the
schema, the admin user is seeded, login works. The securityContext shown for the backend
applies unchanged.
Worker lifecycle
- Graceful shutdown. A run can last up to the Celery task limit (3600 s). On
SIGTERMa worker stops taking tasks and waits for the running ones; with Kubernetes' defaultterminationGracePeriodSeconds: 30, every deploy would kill in-flight runs (the chart sets 3600 s for the run workers, 120 s for the preview workers). Tasks are not redelivered (acks_lateis off, on purpose: outputs in append mode are not idempotent). Keep the grace period above the task limit (raised from Administration → Settings too), or accept that deploys interrupt the longer runs. - A worker killed mid-run (OOMKilled: on cgroup v2 the kernel kills the whole
container; an emptyDir past its
sizeLimit; its node lost) leaves its runs started in Celery's results. The engine tells them apart from running ones by the worker's own record in Valkey (gone, or started again after the run began) and reports them failed, «The worker running it stopped before the end», within about half a minute, instead of waiting forENGINE__RUN_STALE_TIMEOUT_SECONDS. - Gateway and engine. Stopping, they wait 5 s (
preStop) for the Service to drop them, then finish the requests in flight (a preview waits up to 290 s, an upload as long as it takes) for up to their grace period minus 15 s:gateway.terminationGracePeriodSecondsandengineApi.terminationGracePeriodSeconds, 300 s. The orchestrator's is 60 s: it closes the executions it carries as interrupted and stops their steps already with a worker (revoked), so a flow run again does not write on top of them. - Memory. Set a memory limit per pod. The per-child memory cap is derived from the
container limit and
CELERY__WORKER_CONCURRENCY, so the limit is also the tuning knob.
Probes
| Service | Endpoint |
|---|---|
| Gateway | GET /health |
| Engine API | GET /healthcheck/ |
| Frontend | GET /login |
The gateway's and the engine's answer on the event loop, apart from the threads serving requests, so a busy pod is not taken for a dead one; the chart gives every probe 5 s. Workers and beat expose no HTTP port; rely on the restart policy.
Configuration
Settings are environment variables with __ as the nesting delimiter. Every one of
them, with its default, its bounds and the values.yaml key the chart sets it from, is
in the configuration reference; the commented starting point is
infrastructure/.env.example. Values marked
secret belong in Kubernetes Secrets.
Comments in env files. Not every tool strips a trailing # comment from a value:
docker run --env-file keeps it as part of the value, and the gateway then refuses to
start on APP__TIMEZONE — worse, a secret with a trailing comment would be silently
wrong. infrastructure/.env.example keeps every comment on its own line; if you derive
ConfigMaps or Secrets from an existing .env, check that no value carries a comment.
| Variable | Gateway | Backend roles | Notes |
|---|---|---|---|
APP__ENV_NAME=production | ✓ | enables the production guard: the gateway refuses to start with development secrets. It has no effect on the engine or the workers — nothing reads it there | |
SECURITY__FERNET_KEY secret | ✓ | ✓ | same value everywhere; encrypts database credentials; every process refuses to start without a valid key |
APP__TIMEZONE | ✓ | timezone in which the gateway interprets cron schedules. Celery beat keeps its own clock (CELERY__TIMEZONE, UTC by default), but the gateway converts schedules to UTC before enqueuing, so leaving beat on UTC is correct | |
APP__ROLE | ✓ | what a gateway process does: api (answer requests), orchestrator (schedules, flow executions, clean-up) or all (both, the default). Set per workload, not in the shared ConfigMap | |
OPENLINEAGE__URL | ✓ | OpenLineage collector (Marquez, DataHub…); with OPENLINEAGE__FILE (JSON Lines) and OPENLINEAGE__NAMESPACE. Empty = off. The API key goes in the secret (secrets.openlineageApiKey) | |
APP__GATEWAY_THREADS | ✓ | threads per gateway process (default 40; 12 is what the chart and the measurements use: fewer serve more). The connection pool follows it | |
ORCHESTRATOR__DEAD_AFTER_SECONDS, ORCHESTRATOR__QUEUE_TIMEOUT_SECONDS, ORCHESTRATOR__MAX_CONCURRENT | ✓ | when a silent execution is given up for dead (60), when a queued one is no longer started (900), how many one orchestrator carries at once (20) | |
DB__HOST, DB__PORT, DB__USER, DB__PASSWORD secret, DB__NAME | ✓ | metadata Postgres | |
DB__AUTO_MIGRATE | ✓ | leave it false (the default): the schema is migrated by the init container (python -m app.db.migra) and the gateway only checks it. true is for the development compose | |
AUDIT__RETENTION_MINUTES | ✓ | how long the audit log is kept, in minutes; 0 (the default) = forever. The privacy notice can quote it (app.auditRetentionMinutes) | |
JWT__SECRET secret, JWT__ACCESS_TTL_MINUTES | ✓ | signs the session tokens; the longest a session lasts, 720 minutes by default | |
AUTH__COOKIE_DOMAIN | ✓ | the session cookie's domain; empty (the default) = the gateway's host. Set the shared domain (example.com) only when the UI and the API are on two hosts of it (auth.cookieDomain) | |
AUTH__PERSONAL_TOKENS, AUTH__API_TOKEN_MAX_DAYS | ✓ | API tokens (docs/api.md): whether people may create their own (true), and the longest a token may last (365 days) — auth.personalTokens, auth.apiTokenMaxDays | |
WEBHOOKS__RETENTION_DAYS, WEBHOOKS__TIMEOUT_SECONDS | ✓ | webhook call history kept (30 days) and how long a receiver may take (10 s). Calls go out from the orchestrator pods, which the chart's egress policy does not restrict; if your cluster limits their egress otherwise, open it to the receivers | |
AUTH__SESSION_IDLE_MINUTES | ✓ | minutes without activity after which a session closes; 0 (the default) = never. The open page signs out by itself after a one-minute warning (auth.sessionIdleMinutes in the chart) | |
AUTH__ADMIN_EMAIL, AUTH__ADMIN_PASSWORD secret, AUTH__ADMIN_NAME | ✓ | break-glass admin, seeded at first start | |
APP__CORS_ORIGINS | ✓ | JSON list with the public frontend URL, e.g. ["https://app.example.com"]. Needed only when the frontend is served from a different host than the gateway — then set AUTH__COOKIE_DOMAIN too; the reference ingress puts both on one host, where it can stay unset (app.corsOrigins in the chart). * is refused: the session is a cookie | |
ENGINE__BASE_URL | ✓ | internal URL of the engine API | |
ENGINE__BUCKET | ✓ | the gateway's own copy of the bucket name; it compares it against the bucket in every request and rejects a mismatch with 403. It must equal STORAGE__BUCKET, so the chart and the compose file both derive it from the same single value — set it by hand only if you know why | |
ENGINE__SETTINGS_PANEL | ✓ | Administration → Settings may change about a hundred settings of the gateway and the engine at runtime (docs/engine-settings.md); the ones read at start are applied by restarting their pods one at a time (settingsRollout.enabled). false keeps the environment the only source (engine.settingsPanel) | |
REDIS__HOST, REDIS__PORT, REDIS__DB | ✓ | broker and cache index | |
STORAGE__ENDPOINT, STORAGE__ACCESS_KEY, STORAGE__SECRET_KEY secret, STORAGE__BUCKET, STORAGE__REGION | ✓ | object storage | |
CELERY__WORKER_CONCURRENCY | ✓ | per worker pod | |
EXPORT__MAX_ROWS | ✓ | rows a CSV download may have (default 5,000,000; 0 = no cap): the browser holds the whole file in memory | |
CACHE__TTL_SECONDS, CACHE__SWEEP_INTERVAL_SECONDS | ✓ | step cache lifetime and sweep cadence | |
CLICKHOUSE_EXTERNAL__* | ✓ | optional ClickHouse engine, off when the host is empty | |
BIGQUERY__* | ✓ | optional BigQuery engine, off when the project is empty; credentials secret | |
AI__* | ✓ | optional AI assistant, off without a base URL and a key (secret). Set the spending caps (AI__MAX_COST_PER_DAY_USD, AI__MAX_COST_PER_DAY_TOTAL_USD) wherever people you do not know can sign in | |
OIDC__* | ✓ | optional SSO, off when the issuer is empty | |
NUXT_PUBLIC_API_BASE, NUXT_PUBLIC_BUCKET | frontend, read at startup: public gateway URL and default bucket |
The Helm chart sets the variables above from named values; any other one goes through
extraEnv, by its variable name (secrets through existingSecret). The Helm value
column of the configuration reference says which is which.
SSO behind real hostnames. OIDC__REDIRECT_URI is
https://<host>/api/auth/sso/callback and must be registered, identical, on the
identity provider; OIDC__POST_LOGIN_URL is https://<host>/auth/callback. <host> is
the address people open Tabularia at: a sign-in started at another name of the same
server (an IP, an internal alias) loses its cookie on the way back and fails with
tx_missing (the gateway logs both addresses). The gateway allows a minute between its
clocks and the identity provider's; keep NTP on the nodes. Step by step, with
Keycloak: Single sign-on with Keycloak.
Frontend
Build the production image from frontend/Dockerfile.prod; frontend/Dockerfile is
the development server with hot reload and must not reach production. The production
image compiles the app with nuxt build and ships only the compiled output, served by
Nitro on port 3000. Size: 176 MB.
- One image for every environment. The public settings are read at startup, so no
rebuild per environment:
NUXT_PUBLIC_API_BASE(public gateway URL, called by the browser),NUXT_PUBLIC_BUCKET(must equalSTORAGE__BUCKET) and, optionally,NUXT_PUBLIC_GRAFANA_URL. - No server-side API calls. Server rendering never calls the gateway; every API call comes from the browser. The frontend pods need no route to the gateway.
- Same hardening as the other images: uid/gid 10001, read-only root filesystem,
/tmpthe only writable path; thesecurityContextshown for the backend applies unchanged. - Content-Security-Policy from the frontend itself. Every page carries one: scripts
only from
/_nuxtplus Nuxt's own inline scripts, allowed by hash;fetchonly to the site and toNUXT_PUBLIC_API_BASE; frames only the site andNUXT_PUBLIC_GRAFANA_URL. Nothing to configure on the ingress. The session cookie is written by the gateway,HttpOnly(no script on the page can read it) andSameSite=Lax, andSecurewhen the page is served over HTTPS — behind a proxy that terminates TLS, throughX-Forwarded-Proto, which ingress-nginx sets.
Verified read-only: the login page renders, a gateway URL set at startup ends up in the
served page, compiled assets are served, and / redirects to /login without a session.
Known application limits that affect operations
- Flow executions are not resumed. An execution whose orchestrator stops or dies
is closed as failed, with a message that says so — at once on a clean stop, after
ORCHESTRATOR__DEAD_AFTER_SECONDS(a minute) when the process is killed. The steps already done have written their output; the flow has to be launched again. Rollouts of the orchestrator make this visible in the run history. - Versions before the queue must not run next to this one. A gateway from before
APP__ROLEexisted ran the scheduler in process and knows nothing of the queue: stop it before starting the new version (the chart's README has the two commands). - The interface and the gateway must be on the same domain. The browser's session
is a cookie the gateway writes (
HttpOnly,SameSite=Lax), and the interface's server must see it too — the reference ingress serves both from one host, with the gateway under/api. Two hosts of one domain work withAUTH__COOKIE_DOMAIN; two unrelated domains leave the browser without a session. - Removing someone from an SSO group takes effect at their next sign-in, or at once if an administrator closes their sessions (Audit page). Disabling the account, or changing its password, closes them by itself.