TulipFarm deployment guide ============================================================ You are an LLM with shell access. A human asked you to deploy TulipFarm and pointed you at this file. Work top to bottom: it is the complete configuration surface plus a verification for every action. TRUST BOUNDARY: READ FIRST ------------------------------------------------------------ Verified targets, booted end to end in TulipFarm's CI: Docker Compose. Every other platform is UNVERIFIED. On an unverified platform you are adapting the contract below to a path nobody has tested. Use these facts, but translate them yourself and never assume a step worked. HOW TO USE THIS FILE ------------------------------------------------------------ 1. After EVERY action, run that step's `Verify:` line and confirm it passes before the next step. A `Check:` line has no automated test. Inspect for the described signal yourself. 2. STOP at the first verification that fails. Do not continue and do not report success; fix the failing step, or hand back to the human, before proceeding. 3. Steps under a `Branch (input = value)` label are alternatives: exactly one applies. Pick the one matching the human's answer to that input's question. Every branch is printed here. Never follow one whose condition you did not choose. 4. Never invent, guess, or hardcode a value marked `(secret)`. Where a `generate:` recipe is shown, run it on the deployment host and keep the output out of this file and any reply. Verification kinds you will meet: http, command, file, env, manual. Zones you will meet in the variable list: set-these, installer-sets, never-set. RUNTIME CONTRACT (true on every platform) ------------------------------------------------------------ Services: - app: API, built web UI, and database migrations port 8080; health: GET /readyz expects 200 - worker: Run dispatch, waits, outbox delivery, cron, and maintenance consumers port 4020; health: GET /livez expects 200 - integration-worker: Slack and GitHub ingress, sync, delivery, retries, and reconciliation port 4030; health: GET /livez expects 200 Dependencies: - postgres [required] (drivers: postgres): PostgreSQL 17 with pgvector. Transactions, leases, inbox/outbox, and recovery all depend on the correctness core, so it is not replaceable. - blob [required] (drivers: filesystem, s3, azure): Object storage for every File's bytes, uploaded by people and generated by agents. Durable state: every path must survive restart and upgrade: - TF_DATA_DIR: holds Generated secrets.env, worker.env, integration-worker.env, and bucket.env if lost: It holds the key that decrypts every Secret this instance stores. Losing it means losing them, because the database holds only ciphertext. Booting a fresh ENCRYPTION_KEY against a populated database refuses to start rather than silently orphaning them. - SOUL_PATH: holds The live soul git checkout, including Resource schemas, Agents, Skills, Routines, Integrations if lost: Losing it loses everything the business built, and its git history is the audit trail for who changed what. - postgres data directory: holds Every Record, Run, Turn, audit event, vector index, and runtime installation/business/hosting association if lost: Losing it loses all business data. Restoring it without TF_DATA_DIR leaves every stored Secret undecryptable. Restoring only the soul does not restore installation identity; retain the matching database, business identifier, and any installation UUID assertion. ENVIRONMENT VARIABLES (full surface: 105 variables) ------------------------------------------------------------ Zone tells you whether you may change a variable: set-these normal configuration; change it to suit the deployment installer-sets overridable, but a wrong value gives a broken or non-standard install never-set internal plumbing; setting it by hand breaks things Every variable is listed, including ones no verified target uses. That is exactly what lets this guide work on a platform we have never booted. A variable marked (secret) holds key material: never invent, guess, or hardcode its value. Where a `generate:` recipe is shown, run it on the deployment host and keep the output out of this file, your reply, and version control. The "consumers" of a variable are the processes that read it: app, web, worker, integration-worker, compose, installer. compose and installer never reach the application, so setting one and expecting the app to honour it is already a mistake. [Group: Required boot configuration] DATABASE_URL zone: set-these | required: yes | consumers: app, worker, integration-worker does: PostgreSQL connection string. Must start with postgres:// or postgresql://. default: none if set wrong: Without it the process cannot start. Pointed at the wrong database, migrations run against data that was never TulipFarm's. ENCRYPTION_KEY (secret) zone: set-these | required: Yes outside managed data-dir boot | consumers: app, worker, integration-worker does: 32-byte base64 key-encryption key for every stored Secret. generate: openssl rand -base64 32 if set wrong: A fresh key against a database that already holds encrypted Secrets refuses to boot, by design, rather than orphaning them. JWT_SECRET (secret) zone: set-these | required: Yes outside managed data-dir boot | consumers: app does: 32-byte base64 signing secret for auth and session surfaces. generate: openssl rand -base64 32 if set wrong: Changing it invalidates every existing session. WEBHOOK_SIGNING_SECRET (secret) zone: set-these | required: Yes outside managed data-dir boot | consumers: app, integration-worker does: 32-byte base64 secret for webhook signature verification. generate: openssl rand -base64 32 if set wrong: Changing it makes every already-registered provider webhook fail verification. SOUL_PATH zone: set-these | required: Yes, unless SOUL_ROOT is set | consumers: app, worker, integration-worker does: Absolute path to the live soul git checkout. default: none if set wrong: Mutually exclusive with SOUL_ROOT. Setting neither leaves the instance with no configuration to load. SOUL_ROOT zone: set-these | required: Yes, unless SOUL_PATH is set | consumers: app, worker, integration-worker does: Root for per-business soul checkouts, resolved as //soul. default: none if set wrong: Mutually exclusive with SOUL_PATH. [Group: Networking and URLs] PORT zone: set-these | required: no | consumers: app does: API listen port. default: 4010 from source, 8080 in the production image if set wrong: Changing it without changing the platform's port mapping makes the instance unreachable while appearing healthy internally. PUBLIC_URL zone: set-these | required: no | consumers: app does: Public origin users reach. Drives CORS fallback, cookie secure behaviour, and channel-link URLs. default: http://localhost:4000 if set wrong: Wrong here and generated links point somewhere users cannot reach. An address saved under Business then About overrides it unless PUBLIC_ORIGINS_LOCKED is true. PUBLIC_API_URL zone: set-these | required: no | consumers: app does: Public API origin used for Integration OAuth callbacks and webhooks. default: PUBLIC_URL in Docker Compose, otherwise http://localhost:${PORT} if set wrong: Set it separately only when the API has a different public origin; otherwise OAuth callbacks return to an origin the provider cannot reach. PUBLIC_ORIGINS_LOCKED zone: set-these | required: no | consumers: app does: Set true to make the environment origins authoritative and disable changes from Business then About. default: false if set wrong: Left false, an in-app value silently overrides the environment, so the deployed configuration is not the effective one. CORS_ORIGIN zone: set-these | required: no | consumers: app does: Browser origin allowed to call the API with credentials. default: PUBLIC_URL, then http://localhost:${VITE_PORT} if set wrong: Wrong here and the browser silently blocks every authenticated call, which presents as an app that loads but does nothing. VITE_PORT zone: set-these | required: no | consumers: web, app does: Web dev-server port, and the API's CORS fallback. default: 4000 if set wrong: Development only; changing it without CORS_ORIGIN breaks local authenticated calls. VITE_API_URL zone: set-these | required: no | consumers: web does: Browser API base URL for the web app, read at build time. default: http://localhost:4010 if set wrong: Baked into the built client, so changing it requires a rebuild, not a restart. VITE_API_TOKEN (secret) zone: set-these | required: no | consumers: web does: Optional bearer token the web dev client adds to API calls. default: none if set wrong: Development convenience only. Setting it in a built image ships a credential to every browser that loads the app. WEB_DIST zone: installer-sets | required: no | consumers: app does: Directory of built web assets for the API to serve in combined image mode. default: none if set wrong: Wrong here and the API serves no UI while still answering on its API routes. INTERNAL_API_URL zone: installer-sets | required: Yes for worker processes | consumers: worker, integration-worker does: Base URL workers use for service-only internal API calls. default: none if set wrong: Without it workers cannot reach the API, so Runs are claimed and never progress. [Group: Product telemetry] TULIPFARM_TELEMETRY_LEVEL zone: set-these | required: no | consumers: app does: Maximum product telemetry level selectable in Settings then Telemetry; 0 is mandatory bootstrap only, 1 adds daily counts, and 2 adds named inventory. default: 2 if set wrong: Bootstrap includes business identity and sanitized URLs at every level. Lowering this cap stops future reports above it; it does not erase received data. TULIPFARM_TELEMETRY_URL zone: set-these | required: no | consumers: app does: HTTPS endpoint receiving versioned product telemetry reports. default: TulipFarm HTTPS telemetry collector if set wrong: An unreachable collector leaves reports pending for bounded retries and never blocks startup. Only production instances transmit. TULIPFARM_DEPLOYMENT_METHOD zone: installer-sets | required: no | consumers: app does: Deployment method included in the one-time bootstrap report; Compose sets compose. default: unknown if set wrong: Leave unknown when the deployment method cannot be identified reliably. [Group: Headless seeding] ADMIN_EMAIL zone: set-these | required: no | consumers: app does: Email for the first admin account during headless boot. default: none if set wrong: Set with ADMIN_PASSWORD it skips the browser setup wizard. In production, setting both without LLM_API_KEY refuses to boot. ADMIN_PASSWORD (secret) zone: set-these | required: no | consumers: app does: Password for the first admin account during headless boot. default: none if set wrong: Read once at first boot. Leaving it in the environment afterwards leaves an admin credential in plaintext where every process can read it. SKIP_ADMIN_BOOTSTRAP zone: set-these | required: no | consumers: app does: Development-only switch that skips headless seeding so the browser setup wizard runs even when ADMIN_EMAIL and ADMIN_PASSWORD are set. default: none if set wrong: It exists because local development seeds a fixed admin, which would otherwise make the wizard unreachable without editing the environment file. Setting it under NODE_ENV=production refuses to boot rather than leaving a headless deployment waiting at a wizard nobody is watching. LLM_API_KEY (secret) zone: set-these | required: Required with admin credentials in production | consumers: app does: Seeded into the selected provider Secret during headless boot. default: none if set wrong: Without it a headless production boot refuses to start rather than creating an instance whose agents cannot think. LLM_PROVIDER zone: set-these | required: no | consumers: app does: Headless provider for LLM_API_KEY. openai is the other accepted value. default: anthropic if set wrong: Subscription providers cannot be seeded headlessly, so naming one silently leaves the instance without a usable model. BUSINESS_NAME zone: set-these | required: no | consumers: app does: Written to soul business metadata during headless boot. default: none if set wrong: Read only at first boot; later changes belong in the product, not the environment. BUSINESS_DESCRIPTION zone: set-these | required: no | consumers: app does: Written to soul business metadata during headless boot. default: Empty string when BUSINESS_NAME is set if set wrong: Read only at first boot. NODE_ENV zone: installer-sets | required: no | consumers: app, worker, integration-worker does: Enables production-only headless validation and non-development route behaviour. default: Depends on the process manager if set wrong: Left unset in production, development-only routes and looser validation stay reachable. [Group: Soul and git] SOUL_GIT_REMOTE_URL zone: set-these | required: no | consumers: app does: Remote URL cloned on first boot or synced later. default: Persisted soul git config, if present if set wrong: Changing it on a running instance repoints the audit trail at a different history. SOUL_GIT_CREDENTIAL (secret) zone: set-these | required: no | consumers: app does: Credential used for soul remote sync. default: none if set wrong: Without it a private remote fails to sync, and the instance runs on its local checkout with no off-box copy of the configuration. BOT_GIT_NAME zone: set-these | required: no | consumers: app does: Git author name for soul commits. default: tulipfarm-bot if set wrong: Cosmetic, but it is what appears in the audit trail for every agent-authored change. BOT_GIT_EMAIL zone: set-these | required: no | consumers: app does: Git author email for soul commits. default: tulipfarmhq@gmail.com if set wrong: Cosmetic, but it is what appears in the audit trail for every agent-authored change. GIT_TERMINAL_PROMPT zone: never-set | required: no | consumers: app does: Disables interactive git credential prompts. default: Forced to 0 if set wrong: Allowing a prompt makes a failed sync hang forever instead of failing, because nothing is attached to answer it. BUSINESS_ID zone: installer-sets | required: no | consumers: app, worker, integration-worker does: Local business identifier, distinct from the durable runtime-installation identity. default: tulipfarm-local if set wrong: Keep the existing value across upgrades and use it in all three runtime processes. A mismatch with the persisted runtime association refuses startup. RUNTIME_INSTALLATION_ID zone: installer-sets | required: no | consumers: app, worker, integration-worker does: Optional UUID assertion for this runtime installation. Normally omitted or empty; PostgreSQL generates and persists an identity shared by the API and both workers. default: none if set wrong: Must match the stored identity once initialized; conflicts refuse startup without overwriting it. On a new database it establishes the identity. It grants no service access or customer ownership and is separate from the anonymous product telemetry identifier. RUNTIME_HOSTING_AUTHORITY zone: installer-sets | required: no | consumers: app, worker, integration-worker does: Explicit hosting selection, shared by the API and both workers. Leave unset or empty, or use `independent`, for supported self-hosting. default: independent if set wrong: Unknown non-empty values fail startup. `tulipfarm` requires an installation UUID and trusted hosted identity verification, which is unavailable in production in this release; it never falls back to independent setup. A stored hosting association cannot be replaced by changing this value. See [boot modes](https://docs.tulipfarm.site/self-hosting/how-boot-modes-work#explicit-hosted-configuration). SOUL_BUNDLE_RETENTION_DAYS zone: set-these | required: no | consumers: app does: How long an unreferenced published execution bundle is kept before the nightly sweep may delete it. The floor is 7. default: 30 if set wrong: Bundles that are active, previously activated, pinned by a Run, cited by an audit event, or still publishing are never deleted at any age, so lowering this cannot break a live Run. [Group: Workers and service credentials] TF_DATA_DIR zone: installer-sets | required: no | consumers: app, worker, integration-worker does: Stores generated secrets.env, worker.env, integration-worker.env, and bucket.env. default: /data when that directory exists if set wrong: This is the durable path that holds the encryption key. Pointing it at ephemeral storage loses every stored Secret on the next restart. WORKER_API_CREDENTIAL (secret) zone: installer-sets | required: Yes unless read from the data directory | consumers: worker does: Service credential for the durable Run worker. default: none if set wrong: Without it the worker cannot authenticate, so Runs are never executed and Chat Turns hang mid-stream. INTEGRATION_WORKER_API_CREDENTIAL (secret) zone: installer-sets | required: Yes unless read from the data directory | consumers: integration-worker does: Service credential for the integration worker. default: none if set wrong: Without it inbound provider events are accepted and never processed. WORKER_PORT zone: set-these | required: no | consumers: worker does: Worker health probe port. default: 4020 if set wrong: Health probes and the readiness gate have nowhere to answer. WORKER_OWNER zone: never-set | required: no | consumers: worker does: Lease owner recorded on claimed Runs. default: : if set wrong: Two workers sharing an owner string can reclaim each other's live Runs. WORKER_RUN_POLL_MS zone: set-these | required: no | consumers: worker does: Durable Run dispatch poll interval. default: 1000 if set wrong: Raising it adds latency to every Chat Turn; lowering it adds database load. WORKER_WAIT_SWEEP_MS zone: set-these | required: no | consumers: worker does: Wait timer sweep interval. default: 5000 if set wrong: Raising it delays every timer-based Routine step. WORKER_OUTBOX_POLL_MS zone: set-these | required: no | consumers: worker does: Event outbox delivery poll interval. default: 1000 if set wrong: Raising it delays outbound delivery of every emitted event. WORKER_BATCH_SIZE zone: set-these | required: no | consumers: worker does: Maximum Runs claimed per dispatch batch. default: 25 if set wrong: Raising it increases throughput and the number of Runs lost to a single crash. WORKER_LEASE_MS zone: set-these | required: no | consumers: worker does: Run lease duration. default: 60000 if set wrong: Must exceed WORKER_RUN_POLL_MS or the worker refuses to start. WORKER_RUN_MAX_LIFETIME_MS zone: set-these | required: no | consumers: worker does: Longest a single Run keeps renewing its lease before the worker abandons it for reclaim. default: 900000 if set wrong: Must exceed WORKER_LEASE_MS or the worker refuses to start. WORKER_DRAIN_TIMEOUT_MS zone: set-these | required: no | consumers: worker does: Worker shutdown drain budget. default: 15000 if set wrong: Set below the platform's termination grace period, or in-flight Runs are killed rather than drained. WORKER_MAINTENANCE zone: installer-sets | required: no | consumers: worker does: Enables singleton maintenance consumers on one worker replica. default: false if set wrong: Enabling it on more than one replica runs singleton work concurrently. INTEGRATION_WORKER_PORT zone: set-these | required: no | consumers: integration-worker does: Integration-worker health probe port. default: 4030 if set wrong: A port collision presents as a worker that will not start. INTEGRATION_WORKER_DRAIN_TIMEOUT_MS zone: set-these | required: no | consumers: integration-worker does: Integration-worker shutdown drain budget. default: 15000 if set wrong: Set below the platform's termination grace period, or in-flight delivery is killed. [Group: Sessions, Secrets, and limits] SESSION_TTL_SECONDS zone: set-these | required: no | consumers: app does: Session cookie lifetime in seconds. default: 604800 if set wrong: Lowering it signs people out sooner; raising it widens the window a stolen cookie stays usable. ENCRYPTION_KEY_PREVIOUS (secret) zone: set-these | required: no | consumers: app does: Previous 32-byte base64 key, read during key rotation. default: none if set wrong: Removing it before rotation completes leaves anything still encrypted under the old key undecryptable. API_TOKEN_PEPPER (secret) zone: set-these | required: no | consumers: app does: 32-byte base64 HMAC key for hashing personal API tokens. generate: openssl rand -base64 32 if set wrong: Changing it invalidates every existing personal API token at once. RECOVERY_KEY (secret) zone: set-these | required: no | consumers: app does: Offline recovery key, consumed by the key recovery command. default: none if set wrong: Command-line use only. Leaving it in a running process's environment defeats the point of an offline key. HOOKS_DISABLED zone: never-set | required: no | consumers: app does: Set true to skip Resource hook execution. default: none if set wrong: Disables every side effect Records are supposed to trigger. A debugging switch, not configuration. AUTHZ_MODE zone: set-these | required: no | consumers: app does: Set to the exact value shadow to rehearse an authorization change. The engine still evaluates, but every route falls back to its declared admin or member check. default: enforcing if set wrong: Shadow mode can refuse nobody, so leaving it on means a policy that looks applied is not. Every disagreement is logged as authz.divergence; run in shadow until that log is quiet, then unset it. AUTHZ_MODEL_GATE zone: set-these | required: no | consumers: app does: Set to the exact value enforcing to make model-access decisions refuse rather than only report. default: shadow if set wrong: Defaults to shadow so an upgrade cannot lock a deployment out of its own models. Enforcing it before the divergence log is quiet denies real callers. [Group: File storage] BLOB_STORE zone: set-these | required: no | consumers: app, worker does: filesystem, s3 or azure. Exists only to force the choice; naming a bucket, or an Azure account or connection string, already selects the driver. default: Inferred from S3_BUCKET or the Azure variables if set wrong: Setting a driver without its credentials refuses to boot rather than silently storing files on a disk that vanishes. S3_BUCKET zone: set-these | required: S3 only | consumers: app, worker does: Bucket that holds every File's bytes. default: none if set wrong: Naming it selects the S3 driver, so a typo here silently changes which driver runs. S3_REGION zone: set-these | required: no | consumers: app, worker does: Signing region. Every S3-compatible provider signs with one, even single-region ones. default: us-east-1 if set wrong: A wrong region fails at request-signing time, which reads as an auth error rather than a configuration one. S3_ENDPOINT zone: set-these | required: no | consumers: app, worker does: Provider endpoint. Omit it for Amazon S3 itself. default: Amazon's own host if set wrong: Setting it also turns on path-style addressing by default, because a custom endpoint is usually MinIO, Ceph, or a host reached by IP. S3_ACCESS_KEY_ID (secret) zone: set-these | required: S3 only | consumers: app, worker does: Access key for the bucket. default: none if set wrong: Provisioned automatically when the bundled bucket service is on. S3_SECRET_ACCESS_KEY (secret) zone: set-these | required: S3 only | consumers: app, worker does: Secret key for the bucket. default: none if set wrong: Provisioned automatically when the bundled bucket service is on. S3_FORCE_PATH_STYLE zone: set-these | required: no | consumers: app, worker does: Use path-style bucket addressing. default: On whenever S3_ENDPOINT is set if set wrong: MinIO, Ceph, and any host reached by IP cannot serve virtual-hosted bucket names. S3_PREFIX zone: set-these | required: no | consumers: app, worker does: Key prefix, for sharing one bucket with something else. default: none if set wrong: Changing it on a populated bucket hides every File already stored under the old prefix. AZURE_STORAGE_CONTAINER zone: set-these | required: Azure only | consumers: app, worker does: Blob container that holds every File's bytes. default: none if set wrong: Naming it, with a credential, selects the Azure driver; a typo silently changes which container the instance reads and writes. AZURE_STORAGE_CONNECTION_STRING (secret) zone: set-these | required: Azure, unless AZURE_STORAGE_ACCOUNT and AZURE_STORAGE_KEY are set | consumers: app, worker does: Full Azure Storage connection string. The one credential shape that also carries a sovereign-cloud or Azurite endpoint. default: none if set wrong: Naming it selects the Azure driver. Present, it wins over account-and-key. AZURE_STORAGE_ACCOUNT zone: set-these | required: Azure, unless AZURE_STORAGE_CONNECTION_STRING is set | consumers: app, worker does: Storage account name, paired with AZURE_STORAGE_KEY, for the public Azure cloud. default: none if set wrong: Naming it selects the Azure driver. Without AZURE_STORAGE_KEY the instance refuses to boot rather than starting without a usable credential. AZURE_STORAGE_KEY (secret) zone: set-these | required: Azure, unless AZURE_STORAGE_CONNECTION_STRING is set | consumers: app, worker does: Account key for AZURE_STORAGE_ACCOUNT. default: none if set wrong: The shared key that authorises every read, write and same-account copy; wrong here reads as an auth error at boot, not a configuration one. BUCKET_ADMIN_URL zone: installer-sets | required: no | consumers: app does: Admin API of the bundled bucket service. The API provisions the bucket and its access key there on first boot. default: http://bucket:3903 in Compose if set wrong: Set it empty to use an external provider instead; left pointing at a bundled service that is not running, first boot fails to provision storage. [Group: Database and knowledge tuning] DATABASE_URL_MIGRATIONS (secret) zone: set-these | required: no | consumers: app does: Separate connection string used only to run migrations, for deployments where the runtime role cannot alter schema. default: none if set wrong: Unset, migrations run as the runtime role, which then needs schema privileges it otherwise would not. PG_POOL_MAX zone: set-these | required: no | consumers: app, worker, integration-worker does: Maximum PostgreSQL pool connections per process. default: none if set wrong: Every replica opens its own pool, so the effective total is this times the replica count, against the server's own connection limit. PG_CONNECTION_TIMEOUT_MS zone: set-these | required: no | consumers: app, worker, integration-worker does: How long to wait for a pool connection before failing. default: none if set wrong: Raising it turns pool exhaustion into slow requests rather than fast errors. PG_STATEMENT_TIMEOUT_MS zone: set-these | required: no | consumers: app, worker, integration-worker does: Server-side statement timeout. default: none if set wrong: Set too low, long migrations and large retrieval queries are cancelled mid-flight. PG_IDLE_TIMEOUT_MS zone: set-these | required: no | consumers: app, worker, integration-worker does: How long an idle pool connection is kept before it is closed. default: none if set wrong: Set too high, idle connections hold server slots; too low, every burst pays to reopen them. PG_IDLE_IN_TRANSACTION_TIMEOUT_MS zone: set-these | required: no | consumers: app, worker, integration-worker does: Server-side timeout for a session left idle inside an open transaction. 0 disables it, keeping the server default. default: none if set wrong: A transaction left open holds its locks; without this a stuck client can block others indefinitely. KNOWLEDGE_TRGM_FALLBACK zone: set-these | required: no | consumers: app does: Enables pg_trgm typo tolerance for lexical page search unless set to 0 or false. default: on if set wrong: Disabling it makes misspelled searches return nothing rather than near matches. KNOWLEDGE_RERANK zone: never-set | required: no | consumers: app does: Opts into the wired rerank seam. default: none if set wrong: The enabled stage is an honest not-implemented stub, so turning it on changes nothing today. KNOWLEDGE_GRAPHRAG zone: never-set | required: no | consumers: app does: Graph-augmented retrieval switch. default: none if set wrong: Internal retrieval tuning; changing it alters what agents cite without any product-level control. KNOWLEDGE_GRAPH_EXPAND zone: never-set | required: no | consumers: app does: Graph expansion switch for retrieval candidates. default: none if set wrong: Internal retrieval tuning; widening candidates costs latency on every retrieval. KNOWLEDGE_AUTHORED_PAGES_IN_RETRIEVAL zone: never-set | required: no | consumers: app does: Whether authored pages join retrieval candidates. default: none if set wrong: Internal retrieval tuning; changing it alters what agents cite. [Group: Skills, sandbox, and install sources] MARKETPLACE_SOURCE zone: set-these | required: no | consumers: app does: Skills marketplace source repository. default: tulipfarm/skills if set wrong: Pointing it elsewhere installs Skills nobody on this project reviewed. BUNDLED_SKILLS_DIR zone: never-set | required: no | consumers: app does: Overrides where bundled Skills are read from. default: The image or repo bundled skills directory if set wrong: Development override. Wrong here and the instance ships with no Skills. BUNDLED_INTEGRATIONS_DIR zone: never-set | required: no | consumers: app does: Overrides where bundled Integrations are read from. default: The image or repo bundled integrations directory if set wrong: Development override. Wrong here and no Integration is installable. SANDBOX_RUNTIME_IMAGE zone: never-set | required: no | consumers: app does: Exact repository@sha256 image used for published Skill shell, TypeScript, and Python Tools. default: none if set wrong: Development only. Ignored as an execution backend in production, so setting it there gives false confidence that sandboxing changed. SANDBOX_RUNTIME_IMAGE_DIGEST zone: never-set | required: no | consumers: app does: Pinned digest for the sandbox runtime image. default: none if set wrong: Development only, and meaningless without SANDBOX_RUNTIME_IMAGE. GIT_SOURCE_ALLOWED_HOSTS zone: set-these | required: no | consumers: app does: Exact hosts a Skill or Integration may be installed from. default: none if set wrong: Widening it widens what code this instance will fetch and run. GIT_SOURCE_ALLOW_LOCAL_PATHS zone: never-set | required: no | consumers: app does: Permits installing from local filesystem paths. default: none if set wrong: Development only. Enabling it in production lets a local path become an install source. TULIPFARM_VERSION zone: installer-sets | required: no | consumers: app, compose does: Version reported by the update-check endpoint, and the image tag Compose pulls. default: dev if set wrong: In Compose this selects which image runs, so it is not cosmetic there. [Group: Compose-consumed values] POSTGRES_PASSWORD (secret) zone: set-these | required: Bundled PostgreSQL only | consumers: compose does: Password for the bundled PostgreSQL container, and for the DATABASE_URL generated from it. default: tulipfarm if set wrong: The shipped default is a well-known value. Anything reachable beyond localhost must change it. HOST_PORT zone: set-these | required: no | consumers: compose does: Host port published by the Compose stack. default: 8080 if set wrong: Two instances on one machine must not share it. HOST_BIND zone: set-these | required: no | consumers: compose does: Host interface Compose binds for the app port. default: 0.0.0.0 if set wrong: The default publishes on every interface. Set 127.0.0.1 when a reverse proxy terminates in front. COMPOSE_PROJECT_NAME zone: installer-sets | required: no | consumers: compose does: Namespaces every container and volume. default: tulipfarm if set wrong: Renaming it on an existing install orphans that install's database and soul repo. EXTERNAL_DATASTORE zone: set-these | required: no | consumers: compose, installer does: Records that DATABASE_URL points at managed PostgreSQL rather than the bundled container. default: false if set wrong: Left false with an external DATABASE_URL, Compose still starts a database nobody uses. [Group: Installer-consumed values] TF_VERSION zone: set-these | required: no | consumers: installer does: Pins the image tag to install, and writes it as TULIPFARM_VERSION. default: latest if set wrong: latest moves under you on every re-run; pin it for anything reproducible. TF_PORT zone: set-these | required: no | consumers: installer does: The port published on the host. A re-run updates the saved port. default: 8080 if set wrong: Two installs on one machine must each have their own. TF_INSTALL_DIR zone: set-these | required: no | consumers: installer does: Where the stack and its .env are written. default: /opt/tulipfarm if set wrong: Moving it after install leaves the original stack running from the old path. TF_PROJECT_NAME zone: set-these | required: no | consumers: installer does: The Compose project name, which prefixes containers and volumes. default: tulipfarm if set wrong: Two installs on one machine must each have their own, or the second collides with the first. TF_RUNTIME zone: set-these | required: no | consumers: installer does: Forces docker or podman instead of detecting one. default: Auto-detected if set wrong: Forcing a runtime that is not installed fails the install outright. TF_BASE_URL zone: installer-sets | required: no | consumers: installer does: Where the installer fetches the Compose file and example environment file from. default: The public TulipFarm download host if set wrong: Pointing it elsewhere means installing software that is not TulipFarm. TF_REF zone: installer-sets | required: no | consumers: installer does: Fetches those files from a specific ref instead of the release path. default: none if set wrong: For testing an unreleased change. Mismatched files fail in confusing ways. TF_LOCAL_SRC zone: installer-sets | required: no | consumers: installer does: Copies installer inputs from a local checkout instead of downloading. default: none if set wrong: For developing the installer itself. TF_TTY_INPUT zone: never-set | required: no | consumers: installer does: Where the uninstall script reads a typed confirmation from. default: /dev/tty if set wrong: Point it at anything that is not a terminal and uninstall fails, because it can no longer ask the question it must ask before destroying data. TF_TTY_OUTPUT zone: never-set | required: no | consumers: installer does: Where the uninstall script writes its confirmation prompt. default: /dev/tty if set wrong: Same failure as TF_TTY_INPUT. Uninstall refuses rather than destroying data unasked. TARGETS ============================================================ Target: Docker Compose [supported: verified in CI] ============================================================ The [one-line installer](https://docs.tulipfarm.site/self-hosting/install) is the fastest path on a machine you have a shell on. When you deploy through a UI instead (Portainer, Coolify, Dokploy, Unraid), you paste a Compose file into a form, and there is no shell to run a script in. TulipFarm's `docker-compose.yml` is written for exactly that: **every variable has a default, and no `.env` is required.** The steps below are generated from this platform's manifest, so they cannot drift from the runtime they describe. The Compose file itself is hand-maintained and published byte-identical (the same artifact CI boots), so this page references it rather than printing a copy that could rot. Step 1: Download the Compose file Fetch the published `docker-compose.yml`. It is the same file the installer downloads and the same one CI boots in the `Compose parity` job, so what you run here is what we test. Deploying through a UI instead? The same file goes straight into [Coolify](https://docs.tulipfarm.site/self-hosting/coolify), [Portainer](https://docs.tulipfarm.site/self-hosting/portainer), or [Dokploy, Unraid, Synology, and CasaOS](https://docs.tulipfarm.site/self-hosting/other-platforms). Those pages cover only what differs per platform. run: curl -fsSLO https://tulipfarm.site/docker-compose.yml Verify (file): `docker-compose.yml` exists in the working directory. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#download Step 2: Start the stack Branch (database = bundled → "The bundled Postgres container"), Where does PostgreSQL live? Four services start: `app` (API, built web UI, and migrations), `worker` (runs, waits, outbox, cron, and maintenance consumers), `integration-worker` (Slack and GitHub ingress, sync, delivery, retries, and reconciliation), and `postgres` (PostgreSQL 17 with pgvector). Open `http://localhost:8080` and finish the [setup wizard](https://docs.tulipfarm.site/self-hosting/install#finish-setup). run: docker compose up -d Verify (http): GET http://localhost:8080/readyz returns 200 within 120s. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz Step 3: Start against a managed database Branch (database = managed → "A managed PostgreSQL 17 I already run"), Where does PostgreSQL live? Point `DATABASE_URL` at your own PostgreSQL 17, set `EXTERNAL_DATASTORE=true`, and start the app, both workers, and the bundled bucket. Only `postgres` is then never created. ```bash docker compose up -d app worker integration-worker bucket ``` Full walkthrough, including the extensions your provider must allow: [Use a managed database](https://docs.tulipfarm.site/self-hosting/managed-database). Verify (http): GET http://localhost:8080/readyz returns 200 within 120s. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz Step 4: What happens to your secrets TulipFarm needs three secrets: `ENCRYPTION_KEY` (which encrypts every secret the instance stores, including LLM keys and integration credentials), `JWT_SECRET`, and `WEBHOOK_SIGNING_SECRET`. If you do not supply them, the app **generates them on first boot** and writes them to `/data/secrets.env` inside the `tulipfarm-data` volume, `mode 0600`. Every later boot reads them back from that file, so sessions and encrypted secrets survive restarts and upgrades. The first-boot log prints the path in a banner you cannot miss. NOTE: **Back up the `tulipfarm-data` volume.** It holds the key that decrypts every secret this instance stores. Losing it means losing them. Restoring the database alone is not enough, because the database only holds ciphertext. If the app starts with a freshly generated `ENCRYPTION_KEY` against a database that already contains encrypted secrets, it **refuses to boot** rather than quietly orphaning them. That error means the data volume was lost: restore it, or set `ENCRYPTION_KEY` back to its original value. Environment variables always win. Set `ENCRYPTION_KEY` yourself (from your platform's secret store, or `openssl rand -base64 32`) and the file lane is never used. That is what the installer does, and it is what you should do for anything beyond a personal instance: a generated key sits on the same host as the database volume, so a full host compromise yields both. Check (manual): The first-boot log banner naming /data/secrets.env, or an ENCRYPTION_KEY you set yourself taking precedence over it. Step 5: Things worth changing Put these in a `.env` file next to `docker-compose.yml`, or in your platform's environment form. All are optional. | Variable | Default | Why you would set it | | --- | --- | --- | | `HOST_PORT` | `8080` | Publish on a different host port. | | `PUBLIC_URL` | `http://localhost:8080` | The address the app assumes until you save the real one in **Business → About**. Drives CORS and the cookie `Secure` flag. | | `PUBLIC_API_URL` | `PUBLIC_URL` | Public origin used for integration OAuth callbacks and webhooks. Set it separately only when the API has a different public origin. | | `PUBLIC_ORIGINS_LOCKED` | `false` | Make environment origins authoritative and read-only in Business → About. | | `POSTGRES_PASSWORD` | `tulipfarm` | Replace the default database password. | | `TULIPFARM_VERSION` | `latest` | Pin one image tag for `app`, `worker`, and `integration-worker` instead of tracking `latest`. | NOTE: The bundled database defaults to the password `tulipfarm`. That is what lets the file run with no prior setup. Postgres demands a password at init time and cannot ask the app for one. It is safe **only** because port 5432 is never published to the host. Do not publish it. On anything reachable from a network you do not control, set `POSTGRES_PASSWORD`. The public address decides whether session cookies are marked `Secure`: an `https://` origin marks them (correct behind TLS), an `http://` origin does not (browsers discard `Secure` cookies sent over plain HTTP, localhost excepted, so marking them would make login silently fail). `PUBLIC_URL` supplies that address at boot; once you save one under **Business → About**, the saved value takes over without recreating the container. Leave it at the default for a local instance; set `http://:8080` for a LAN box; set `https://your-domain` once TLS is in front. Check (manual): The login page loads at the address you set in PUBLIC_URL, and cookies are marked Secure only when that address is https. Step 6: Hardening the instance If you run this instance on the public internet, you must harden the host. * **Lock down SSH:** Restrict port 22 to known IP addresses in your cloud firewall (e.g. Azure NSG, AWS Security Group). If you must leave it open, install `fail2ban` and ensure `PasswordAuthentication no` is set in `/etc/ssh/sshd_config`. * **Put TLS in front:** The app has no built-in TLS terminator and is served over plain HTTP. Run a reverse proxy (like Caddy, Traefik, or Nginx) in front of the app to serve it over HTTPS and redirect port 80 to 443. Set `HOST_BIND=127.0.0.1` in your `.env` so the app is only reachable via the reverse proxy. Check (manual): The app is served securely over HTTPS, port 80 redirects to 443, and port 22 is restricted or protected by fail2ban and key-only auth. Step 7: Use an external S3 provider instead Branch (blob = external → "An external S3 provider"), Where do uploaded and generated files live? The stack ships its own S3 server, the `bucket` service, so uploading an image for an agent to read works with no cloud account. Its access key is generated by `app` on first boot and written to `/data/bucket.env` at `mode 0600`; the bucket publishes no host port, because a file is authorized per request by `app`. Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, DigitalOcean Spaces, Ceph and MinIO all speak the same protocol, and TulipFarm has one driver for all of them. Turn the bundled server off by setting `BUCKET_ADMIN_URL` to an empty value, then supply your own: ```bash BUCKET_ADMIN_URL= S3_BUCKET=my-bucket S3_REGION=eu-central-1 S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... ``` Start without the bundled server: ```bash docker compose up -d app worker integration-worker postgres ``` Omit `S3_ENDPOINT` for Amazon S3 itself. Path-style addressing turns on automatically whenever `S3_ENDPOINT` is set, which is what MinIO, Ceph and any host reached by IP need. NOTE: **Back up the `tulipfarm-bucket` volume too** when you use the bundled server. It holds the file bytes; the `tulipfarm-data` volume holds the key to reach them; the database holds who may see what. All three are needed to restore an instance. Check (manual): An image uploaded in chat opens again, confirming the app reached your S3 provider. Step 8: Health checks Two endpoints, for the two questions an orchestrator asks: | Process | Port | Liveness | Readiness | | --- | --- | --- | --- | | `app` | `8080` | `/livez` | `/readyz`; `/health` is an alias. | | `worker` | `4020` | `/livez` | `/readyz`; also checks the schema floor and required consumers. | | `integration-worker` | `4030` | `/livez` | `/readyz`; also checks the schema floor and required consumers. | | `bucket` | - | `garage status` | Same command; the container has no HTTP client to probe with. | Only `app` publishes a host port. The worker probe ports are internal to Compose. Give startup probes a generous timeout: database migrations run in `app` before it starts listening, and both workers wait for healthy `app` because they never migrate. Verify (http): GET http://localhost:8080/livez returns 200. Step 9: Update Migrations apply automatically on boot, and there are **no down-migrations**. Read [Updating and rollback](https://docs.tulipfarm.site/self-hosting/updating) before your first upgrade. The trailing `image prune` reclaims the image this update replaced. Without it every update leaves a whole orphaned image (~4.5 GB) on disk, and a regularly updated instance runs out. It removes only untagged images, so anything else on the same engine keeps its images. run: docker compose pull && docker compose up -d && docker image prune -f Verify (http): GET http://localhost:8080/readyz returns 200 within 120s. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz Step 10: Running more than one business on one machine Copying `docker-compose.yml` into a second directory does **not** give you a second instance. Compose tells stacks apart by **project name**, not by directory, and the file pins one (`name: tulipfarm`). A second `docker compose up -d` reuses the first stack's containers and volumes, so both businesses end up sharing one database and one soul repo. Give each stack its own project name and host port: ```bash HOST_PORT=8081 docker compose -p acme up -d HOST_PORT=8082 docker compose -p globex up -d ``` That produces separate volumes per stack, such as `acme_tulipfarm-soul`, `globex_tulipfarm-soul`, and so on. Then keep using the same `-p` for every later command; one without it targets `tulipfarm`, not your stack. Setting `COMPOSE_PROJECT_NAME` in each directory's `.env` is the less error-prone option. With the installer, use `TF_PROJECT_NAME` instead. NOTE: Do not rename an **existing** install's project. Volumes are named after it, so the renamed stack starts empty and the old database and soul repo are left orphaned under the previous name. Check (manual): Each project name owns its own volumes. `docker volume ls` shows the per-stack prefix you chose. Step 11: Scale-out is not supported Run **exactly one `app` replica**, **one maintenance `worker`**, and **one `integration-worker`**. The `app` owns the soul git worktree and applies migrations. The `worker` executes every turn and owns cron/maintenance consumers through `WORKER_MAINTENANCE=true`. Scale vertically; horizontal scaling is a deliberate design constraint, not a missing feature. Check (manual): One replica of each service is running. Horizontal scaling is unsupported by design, not a gap to fill. Target: Azure Container Apps [community: not CI-verified] ============================================================ Azure Container Apps runs the published image without a VM or a cluster to maintain. What it does not give you is a **durable disk**, and that single fact shapes every step below: the database and the file bytes must both live outside the app. These steps are generated from this platform's manifest, so they cannot drift from the runtime they describe. The container app **definition** is generated too (from the runtime contract) and offered as a download, so every port, probe path, and environment key you feed Azure is grounded in what TulipFarm actually reads. NOTE: **This target is not booted in CI**. It is `community`, not `supported`. Standing it up requires a real Azure subscription, so no automated job proves this path end to end. Treat the definition as a documented, machine-generated contract rather than a tested install path. Step 1: Download the generated container app definition Fetch the definition TulipFarm generates from its runtime contract. It describes three single-replica workloads named `app` (API, built web UI, and migrations), `worker` (runs, waits, outbox, cron, and maintenance consumers), and `integration-worker` (Slack and GitHub ingress, sync, delivery, retries), with every port, probe path, and environment key derived from what the runtime reads. Only `app` takes external ingress. The workers expose probe-only HTTP servers, so their ingress stays internal; publishing them exposes health endpoints for no benefit. run: curl -fsSLO https://tulipfarm.site/azure-containerapp.yaml Verify (file): `azure-containerapp.yaml` exists in the working directory. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#download Step 2: Create a resource group and environment A Container Apps *environment* is the shared boundary the three workloads live in. Putting all three in one environment lets them reach each other over the internal network and share a Log Analytics workspace. ```bash az group create --name tulipfarm --location eastus az containerapp env create --name tulipfarm-env --resource-group tulipfarm --location eastus ``` Verify (command): `az containerapp env show --name tulipfarm-env --resource-group tulipfarm` exits successfully, expect the environment exists and provisioningState is Succeeded. Step 3: Create Azure Database for PostgreSQL Branch (database = flexible → "Azure Database for PostgreSQL Flexible Server"), Where does PostgreSQL live? Container Apps will not host your database. There is no durable disk to put it on. Azure Database for PostgreSQL Flexible Server is the managed option, and TulipFarm needs **PostgreSQL 17 with the `vector` and `citext` extensions**. Azure gates extensions behind an allow-list, so enabling them is a required step rather than a detail: ```bash az postgres flexible-server create \ --name tulipfarm-db --resource-group tulipfarm \ --version 17 --database-name tulipfarm az postgres flexible-server parameter set \ --name azure.extensions --value vector,citext \ --server-name tulipfarm-db --resource-group tulipfarm ``` Then hand the connection string to the app as a secret. Azure requires TLS, so keep `sslmode=require`. Provider-by-provider extension notes live in [Use a managed database](https://docs.tulipfarm.site/self-hosting/managed-database). Verify (command): `az postgres flexible-server parameter show --name azure.extensions --server-name tulipfarm-db --resource-group tulipfarm` exits successfully, expect the value includes vector and citext. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#database Step 4: Point at a PostgreSQL you run elsewhere Branch (database = external → "A PostgreSQL 17 I run somewhere else"), Where does PostgreSQL live? A database outside Azure works, but confirm two things before you rely on it: it is **PostgreSQL 17**, and it permits `CREATE EXTENSION vector` and `citext`. Container Apps egresses from the environment's outbound addresses, so the database firewall must admit them, and the link must be TLS. Put `?sslmode=require` on the URL. Every query in a Run crosses this hop, so prefer a database in the same region. Verify (env): `DATABASE_URL` is set in the environment. Step 5: Create the storage the files live in Branch (files = azure → "Azure Blob Storage"), Where do uploaded and generated files live? **This step is not optional on this platform.** Container Apps offers no persistent volume with the durability TulipFarm needs, so the `filesystem` blob driver (the default for Compose) will lose every uploaded and generated file on the next revision. Azure Blob Storage speaks no S3-compatible API, which is why TulipFarm ships a dedicated Azure driver. ```bash az storage account create --name tulipfarmfiles --resource-group tulipfarm --sku Standard_LRS az storage container create --name tulipfarm --account-name tulipfarmfiles ``` Naming the container together with a credential selects the Azure driver. Supply one credential shape as either a full connection string, or an account name and key. A connection string wins when both are present. Verify (command): `az storage container show --name tulipfarm --account-name tulipfarmfiles` exits successfully, expect the container exists. Step 6: Point at an S3-compatible provider Branch (files = s3 → "An S3-compatible provider"), Where do uploaded and generated files live? An S3-compatible provider works from Azure as well as Azure Blob does. The requirement is durable object storage, not a particular vendor. Set `S3_BUCKET`, `S3_REGION`, and `S3_ENDPOINT`, and supply the keys as secrets. Omit `S3_ENDPOINT` for Amazon S3 itself; setting it turns on path-style addressing, which is what MinIO, Ceph, and any host reached by IP need. NOTE: Whichever you choose, do **not** fall back to the filesystem driver here. A Container Apps revision replaces its container, and anything written to the container's own disk goes with it. Verify (env): `S3_BUCKET` is set in the environment. Step 7: Create the instance secrets TulipFarm needs three secrets: `ENCRYPTION_KEY` (which encrypts every secret the instance stores, including LLM keys and integration credentials), `JWT_SECRET`, and `WEBHOOK_SIGNING_SECRET`. Generate them once and set them on the app; the generated definition references them by their lowercase, dash-separated names. ```bash az containerapp secret set --name tulipfarm-app --resource-group tulipfarm \ --secrets encryption-key="$(openssl rand -base64 32)" \ jwt-secret="$(openssl rand -base64 32)" \ webhook-signing-secret="$(openssl rand -base64 32)" ``` NOTE: **Supply `ENCRYPTION_KEY` yourself from day one.** The Compose fallback (where the app writes a generated key to `/data/secrets.env`) cannot work here, because that disk does not survive a revision. The database holds only ciphertext, so a lost key means lost secrets. If the app boots with a fresh `ENCRYPTION_KEY` against a database that already holds encrypted secrets, it **refuses to start** rather than orphaning them. Back this key up alongside the database. Verify (command): `az containerapp secret list --name tulipfarm-app --resource-group tulipfarm` exits successfully, expect encryption-key, jwt-secret and webhook-signing-secret are listed. Step 8: Create the three container apps Apply the generated definition once per workload. Each is single-replica by design. Set `minReplicas` and `maxReplicas` to `1` and leave them there. Two copies of `worker` race the soul worktree and the maintenance consumers. Set `WORKER_MAINTENANCE=true` on the single `worker`. Start `app` first: it runs database migrations before it listens, and the workers must start only after it is ready. ```bash az containerapp create --name tulipfarm-app --resource-group tulipfarm \ --environment tulipfarm-env --yaml azure-containerapp.yaml ``` Give `app` a **generous startup probe**. A fresh database can take minutes to migrate, and a liveness probe that fires during migration kills the container and loops forever, which is why the generated definition sets a high startup `failureThreshold`. Verify (command): `az containerapp show --name tulipfarm-app --resource-group tulipfarm` exits successfully, expect runningStatus is Running. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz Step 9: Confirm the app is ready Container Apps assigns the app an FQDN. Confirm it answers `/readyz` before going further; migrations run before `app` listens, so allow a generous timeout on a fresh database. ```bash az containerapp show --name tulipfarm-app --resource-group tulipfarm \ --query properties.configuration.ingress.fqdn -o tsv ``` Check (manual): The FQDN Azure assigned answers /readyz with 200, allowing several minutes on a fresh database while migrations run. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz Step 10: Put it on your own domain Bind a custom domain and let Azure manage the certificate, then set `PUBLIC_URL` to the `https://` origin it serves. `PUBLIC_URL` sets the address at boot; you can change it later under **Business → About** without a redeploy. ```bash az containerapp hostname bind --hostname tulipfarm.example.com \ --name tulipfarm-app --resource-group tulipfarm ``` Container Apps does not buffer responses, so chat's Server-Sent Events stream without extra configuration, unlike an nginx ingress, which needs buffering turned off explicitly. Check (manual): The custom domain serves the app over https and chat tokens stream in as they are produced, not all at once. Step 11: Seed the first admin without a browser Set `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and `LLM_API_KEY` together on `app` and the instance seeds itself and skips the setup wizard. See [headless seeding](https://docs.tulipfarm.site/self-hosting/headless). Check (manual): You can sign in with the ADMIN_EMAIL and ADMIN_PASSWORD you seeded, with no wizard shown. Step 12: Back up the three durable things Nothing inside a container app survives a revision, so all three live outside it, and restoring only one is not enough: - **PostgreSQL** stores every Record, Run, Turn, audit event, and vector index. Flexible Server takes automated backups; confirm the retention window matches what you would accept losing. - **`ENCRYPTION_KEY`** is required because the database holds only ciphertext without it. Keep it somewhere that is not the container app, such as Key Vault. - **The soul** at `SOUL_PATH` stores your entire configuration. On this platform the soul has no durable disk either, so mirror it to a git remote with `SOUL_GIT_REMOTE_URL`. See [Updating and rollback](https://docs.tulipfarm.site/self-hosting/updating) before your first upgrade. Check (manual): A restore drill brings the instance back with its secrets decryptable, with the database, encryption key, and soul all restored together. Step 13: Update Pin one image tag; do not track a moving tag. Each update creates a new revision, and migrations apply automatically on boot. There are **no down-migrations**, so a revision rollback does not undo a schema change. Read [Updating and rollback](https://docs.tulipfarm.site/self-hosting/updating) first. ```bash az containerapp update --name tulipfarm-app --resource-group tulipfarm \ --image ghcr.io/tulipfarm/tulipfarm:v1.2.3 ``` Update `app` first so migrations land before the workers restart against the new schema. Verify (command): `az containerapp show --name tulipfarm-app --resource-group tulipfarm` exits successfully, expect runningStatus is Running on the new revision. Target: Coolify [community: not CI-verified] ============================================================ Coolify runs TulipFarm from the **same `docker-compose.yml` as a plain Compose deploy**, so this page is deliberately short: it covers only what Coolify does differently, which resource type to pick, where its UI wants each value, and how its proxy and backups diverge. For everything the two share (what the secrets are, why the data volume matters, how updates and scale-out behave), follow [Docker Compose](https://docs.tulipfarm.site/self-hosting/docker-compose). Nothing here repeats it; each step below states the Coolify action and links back for the reasoning. NOTE: This target is `community`, not `supported`. Coolify consumes the exact Compose artifact CI boots in the `Compose parity` job, so **the file you paste is tested**, but standing Coolify itself up is not exercised in CI, so the wiring around that file is documented, not booted. Booting a plain Compose stack would prove nothing Coolify-specific, so we do not stamp this integration verified on that basis. Step 1: Create a Docker Compose resource Coolify has no TulipFarm template yet, so add a new resource and choose **Docker Compose**. It does exactly what a template would. Paste the published [`docker-compose.yml`](https://tulipfarm.site/docker-compose.yml) into the editor. No edits are needed to start: every variable has a default, and the app generates its own secrets on first boot. Check (manual): The resource lists the app, worker, integration-worker, and postgres services from the pasted Compose file. Step 2: Set the environment variables Coolify should own Open the resource's **Environment Variables** tab. The point of setting these here rather than letting the app generate them is that Coolify holds them in its own secret storage instead of on the app's `tulipfarm-data` volume: | Variable | Value | Why | | --- | --- | --- | | `ENCRYPTION_KEY` | `openssl rand -base64 32` | Keeps the key in Coolify, off the data volume. Set it **before the first deploy**. A key introduced after the app has generated its own cannot decrypt existing secrets, and the app refuses to boot rather than orphan them. | | `POSTGRES_PASSWORD` | a password of your own | Replaces the file's `tulipfarm` default. | | `PUBLIC_URL` | `https://tulip.example.com` | The public address the app assumes until you save the real one in **Business → About**. | | `TULIPFARM_VERSION` | e.g. `v0.1.0` | Optional. Pin a tag instead of tracking `latest`. | Every other variable and its default lives on [Docker Compose → Things worth changing](https://docs.tulipfarm.site/self-hosting/docker-compose#things-worth-changing); this table is only the handful Coolify's secret storage should hold. Check (manual): ENCRYPTION_KEY is set in the Environment Variables tab before the first deploy, not after it. Step 3: Attach the domain through Coolify's proxy Point a domain at the `app` service on port `8080`. Coolify's proxy terminates TLS and requests the certificate, so you do not run [the TLS setup](https://docs.tulipfarm.site/self-hosting/tls) yourself. Two Coolify-specific things to get right: - **Disable response buffering** on the service if the proxy configuration offers it. Chat streams over Server-Sent Events, and a buffering proxy holds every token until the turn ends, so the reply looks frozen and then arrives all at once. - After the first boot, open **Business → About** and save the exact public domain. TulipFarm then uses it for cookies, OAuth callbacks, webhooks, and shared links with no redeploy. Set `PUBLIC_ORIGINS_LOCKED=true` only if the environment values should stay authoritative. Check (manual): The domain serves the app over https and chat tokens stream in as they are produced, not all at once at the end of the turn. Step 4: Deploy and finish setup Deploy the resource, then watch the logs until the health check on `/readyz` passes. First boot runs database migrations, so give it a minute. Open your domain: the app redirects to `/setup`, where you create the first admin account. Walk through [Finish setup](https://docs.tulipfarm.site/self-hosting/install#finish-setup). Check (manual): The app's health check passes in Coolify and your domain opens the /setup wizard. Step 5: Back up the volumes through Coolify Coolify manages the four Compose file volumes named `tulipfarm-postgres`, `tulipfarm-soul`, `tulipfarm-data`, and `tulipfarm-bucket`. Add all four to Coolify's backup configuration, or use a [managed database](https://docs.tulipfarm.site/self-hosting/managed-database) and back up `tulipfarm-soul`, `tulipfarm-data`, and `tulipfarm-bucket` only. NOTE: `tulipfarm-data` holds the generated `ENCRYPTION_KEY` unless you set one yourself, and `tulipfarm-bucket` holds every uploaded and generated file. The database stores only ciphertext, so losing either volume loses the secrets or files it guards. See [what happens to your secrets](https://docs.tulipfarm.site/self-hosting/docker-compose#what-happens-to-your-secrets). Check (manual): All four named volumes appear in Coolify's backup configuration, or the three non-database volumes do when you use a managed database. Step 6: Update Redeploy the resource. With `TULIPFARM_VERSION` unset that pulls the newest `latest`; if you pinned a tag, bump it first. Migrations run automatically on boot and have **no down-path**. Read [Updating and rollback](https://docs.tulipfarm.site/self-hosting/updating) before the first one. Check (manual): The redeployed resource comes back healthy on the image tag you expected. Step 7: Keep one replica of each service Leave `app`, `worker`, and `integration-worker` at **one replica each**. Coolify's scale control will corrupt the soul git worktree on `app` or duplicate maintenance work on `worker`. Scale vertically; horizontal scaling is a deliberate design constraint. The reasoning is the same everywhere; see [Deploy](https://docs.tulipfarm.site/self-hosting#what-every-deployment-needs). Check (manual): Each of app, worker, and integration-worker runs exactly one replica in Coolify. Target: Kubernetes [community: not CI-verified] ============================================================ Kubernetes buys you cluster-native Secrets, Ingress, and managed PostgreSQL. It buys you nothing toward scale: TulipFarm runs **exactly one replica of each workload** by design, so this is a single-node control plane wearing a cluster's clothes, not a horizontally scaled service. These steps are generated from this platform's manifest, so they cannot drift from the runtime they describe. The chart **values** are generated too (from the runtime contract) and offered as a download, so every port, health path, and environment key you feed a chart is grounded in what TulipFarm actually reads. NOTE: **TulipFarm ships no official Helm chart yet, and this target is not booted in CI**. It is `community`, not `supported`. The values file below is the machine-generated configuration surface; you supply the chart (or plain manifests) that consumes it. Until a CI job stands this up to a ready state, treat it as a documented contract, not a tested install path. Step 1: Download the generated chart values Fetch the `values.yaml` TulipFarm generates from its runtime contract. Every workload, port, health path, and environment key in it is derived from what the runtime reads, so it cannot drift from the image. Fill the keys your deployment needs and delete the rest. The three workloads it describes are single-replica by design: `app` (API, built web UI, and migrations), `worker` (runs, waits, outbox, cron, and maintenance consumers), and `integration-worker` (Slack and GitHub ingress, sync, delivery, retries, and reconciliation). run: curl -fsSLO https://tulipfarm.site/kubernetes-values.yaml Verify (file): `kubernetes-values.yaml` exists in the working directory. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#download Step 2: Create a namespace Give the instance its own namespace so its Secrets, volumes, and workloads are isolated and easy to tear down. run: kubectl create namespace tulipfarm Verify (command): `kubectl get namespace tulipfarm` exits successfully, expect the namespace exists. Step 3: Point at a PostgreSQL you run Branch (database = external → "A PostgreSQL 17 I run myself in or beside the cluster"), Where does PostgreSQL live? TulipFarm needs **PostgreSQL 17 with the `vector` and `citext` extensions**. If you run your own Postgres (a StatefulSet in the cluster, or a box beside it), grant its role permission to `CREATE EXTENSION vector` and `citext`, then hand the connection string to the app as a Secret: ```bash kubectl -n tulipfarm create secret generic tulipfarm-database \ --from-literal=DATABASE_URL='postgresql://user:password@postgres:5432/tulipfarm' ``` A cluster rarely wants a bundled database, which is why the generated values default `dependencies.postgres.external` to `true`. Full extension and permission detail: [Use a managed database](https://docs.tulipfarm.site/self-hosting/managed-database). Verify (command): `kubectl -n tulipfarm get secret tulipfarm-database` exits successfully, expect the DATABASE_URL secret exists. Step 4: Point at a cloud-managed PostgreSQL Branch (database = managed → "A cloud-managed PostgreSQL service"), Where does PostgreSQL live? A managed service (RDS, Cloud SQL, Azure Database, Neon) removes the StatefulSet, but you must confirm it offers **PostgreSQL 17** and **allows the `vector` and `citext` extensions**; some managed providers gate `vector` behind an allow-list you enable first. Point the app at it as a Secret exactly as for a self-run database: ```bash kubectl -n tulipfarm create secret generic tulipfarm-database \ --from-literal=DATABASE_URL='postgresql://user:password@your-host:5432/tulipfarm?sslmode=require' ``` The provider-by-provider extension notes live in [Use a managed database](https://docs.tulipfarm.site/self-hosting/managed-database). Verify (command): `kubectl -n tulipfarm get secret tulipfarm-database` exits successfully, expect the DATABASE_URL secret exists. Step 5: Create the instance Secrets TulipFarm needs three secrets: `ENCRYPTION_KEY` (which encrypts every secret the instance stores, including LLM keys and integration credentials), `JWT_SECRET`, and `WEBHOOK_SIGNING_SECRET`. Generate them once and store them in a Kubernetes `Secret`. The `secretRefs` list in the values file names exactly these: ```bash kubectl -n tulipfarm create secret generic tulipfarm-secrets \ --from-literal=ENCRYPTION_KEY="$(openssl rand -base64 32)" \ --from-literal=JWT_SECRET="$(openssl rand -base64 32)" \ --from-literal=WEBHOOK_SIGNING_SECRET="$(openssl rand -base64 32)" ``` NOTE: **Supply `ENCRYPTION_KEY` from a Secret from day one.** In a cluster the Compose fallback ( where the app writes a generated key to `/data/secrets.env`) is a trap: it survives only if `/data` is a reliably backed-up, shared volume. Lose that volume and the database, which holds only ciphertext, becomes undecryptable. If the app boots with a fresh `ENCRYPTION_KEY` against a database that already holds encrypted secrets, it **refuses to start** rather than orphaning them. Back the Secret up alongside the database. Verify (command): `kubectl -n tulipfarm get secret tulipfarm-secrets` exits successfully, expect the instance secrets exist. Step 6: Configure S3-compatible storage Branch (blob = s3 → "An S3-compatible provider"), Where do uploaded and generated files live? A cluster has no durable local disk, so uploaded and generated files belong in object storage. Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, DigitalOcean Spaces, Ceph, and MinIO all speak the same protocol, and TulipFarm has one driver for all of them. Set `S3_BUCKET`, `S3_REGION`, and `S3_ENDPOINT` in the values `config`, and put the keys in a Secret: ```bash kubectl -n tulipfarm create secret generic tulipfarm-blob \ --from-literal=S3_ACCESS_KEY_ID=... \ --from-literal=S3_SECRET_ACCESS_KEY=... ``` Omit `S3_ENDPOINT` for Amazon S3 itself; setting it turns on path-style addressing, which is what MinIO, Ceph, and any host reached by IP need. Verify (command): `kubectl -n tulipfarm get secret tulipfarm-blob` exits successfully, expect the S3 credentials exist. Step 7: Configure Azure Blob Storage Branch (blob = azure → "Azure Blob Storage"), Where do uploaded and generated files live? Set `AZURE_STORAGE_CONTAINER` in the values `config`, then supply one credential shape as a Secret, either a full connection string, or an account name and key: ```bash kubectl -n tulipfarm create secret generic tulipfarm-blob \ --from-literal=AZURE_STORAGE_CONNECTION_STRING='...' ``` Naming the container with a credential selects the Azure driver. A connection string wins over an account-and-key pair when both are present. Verify (command): `kubectl -n tulipfarm get secret tulipfarm-blob` exits successfully, expect the Azure credentials exist. Step 8: Use a persistent volume for files Branch (blob = filesystem → "A persistent volume (single node only)"), Where do uploaded and generated files live? The filesystem driver stores files under `TF_DATA_DIR`. It is correct only for a **single-node** test cluster: the volume must be `ReadWriteOnce` and bound to the one node `app` runs on, and nothing else can reach those bytes. NOTE: Do not use the filesystem driver on a multi-node cluster. A `ReadWriteOnce` volume follows one pod, so a rescheduled `app` loses every stored file. Use `s3` or `azure` for anything beyond a laptop cluster. Check (manual): The PersistentVolumeClaim backing TF_DATA_DIR is Bound and ReadWriteOnce. Step 9: Feed the values into a chart and apply TulipFarm ships no official chart, so point your own chart (or plain manifests) at the generated values. Whatever consumes them must honour the workload contract: | Workload | Container port | Probes | Volumes | | --- | --- | --- | --- | | `app` | `8080` | `/livez` liveness, `/readyz` readiness | soul at `SOUL_PATH` read-write; `TF_DATA_DIR` read-write | | `worker` | `4020` | `/livez` liveness, `/readyz` readiness | `TF_DATA_DIR` read-only | | `integration-worker` | `4030` | `/livez` liveness, `/readyz` readiness | `TF_DATA_DIR` read-only | Run each workload with `replicas: 1` and `strategy: Recreate`, never `RollingUpdate`: a rolling update briefly runs two copies and can race the soul worktree or the maintenance consumers. Set `WORKER_MAINTENANCE=true` on the single `worker`. Set `podSecurityContext.fsGroup: 1000`, since the image runs as UID `1000` (`node`) and mounted volumes are otherwise not writable. Give `app` a **generous startup probe** on `/readyz`: it runs database migrations before it listens, so a fresh database can take minutes on first boot, and without a startup probe liveness kills `app` mid-migration and loops forever. The workers never migrate. They must start only after `app` is ready. ```bash helm install tulipfarm ./your-chart -n tulipfarm -f kubernetes-values.yaml ``` run: kubectl -n tulipfarm rollout status deployment/app --timeout=600s Verify (command): `kubectl -n tulipfarm rollout status deployment/app --timeout=600s` exits successfully, expect the app deployment finishes rolling out. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz Step 10: Confirm the app is ready Port-forward the `app` Service and confirm it answers `/readyz`. Migrations run before `app` listens, so allow a generous timeout on a fresh database. ```bash kubectl -n tulipfarm port-forward svc/app 8080:8080 & ``` Verify (http): GET http://localhost:8080/readyz returns 200 within 300s. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz Step 11: Expose the app through Ingress Terminate TLS at the Ingress and route **only** to the `app` Service on port `8080`. Never publish `worker` or `integration-worker`; their HTTP servers are probe-only. **Disable response buffering.** Chat streams over Server-Sent Events; a buffering ingress holds every token until the turn completes and the UI looks frozen. On ingress-nginx: ```yaml nginx.ingress.kubernetes.io/proxy-buffering: "off" nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" ``` Set `PUBLIC_URL` in the values `config` to the `https://` origin the Ingress serves. It sets the address at boot; you can change it later under **Business → About** without a rollout. Check (manual): The Ingress serves the app over https and chat tokens stream in as they are produced, not all at once. Step 12: Seed the first admin without a browser Most cluster workflows have no browser at first boot. Use [headless seeding](https://docs.tulipfarm.site/self-hosting/headless): set `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and `LLM_API_KEY` together on `app`, and the instance seeds itself and skips the setup wizard. Check (manual): You can sign in with the ADMIN_EMAIL and ADMIN_PASSWORD you seeded, with no wizard shown. Step 13: Back up the three durable things Three things must survive a cluster rebuild, and restoring only one is not enough: - **PostgreSQL** stores every Record, Run, Turn, audit event, and vector index. - **The Secret behind `ENCRYPTION_KEY`** (and the `TF_DATA_DIR` volume, if you let the app generate the key there). The database holds only ciphertext without it. - **The soul** at `SOUL_PATH` stores your entire configuration, unless it is mirrored to a git remote with `SOUL_GIT_REMOTE_URL`. See [Updating and rollback](https://docs.tulipfarm.site/self-hosting/updating) before your first upgrade. Check (manual): A restore drill brings the instance back with its secrets decryptable, with the database, encryption key, and soul all restored together. Step 14: Update Pin one image tag in `image.tag`; do not track a moving tag in a cluster. Migrations apply automatically on boot and there are **no down-migrations**. Read [Updating and rollback](https://docs.tulipfarm.site/self-hosting/updating) before your first upgrade. ```bash helm upgrade tulipfarm ./your-chart -n tulipfarm -f kubernetes-values.yaml ``` run: kubectl -n tulipfarm rollout status deployment/app --timeout=600s Verify (command): `kubectl -n tulipfarm rollout status deployment/app --timeout=600s` exits successfully, expect the app deployment finishes rolling out. On fail: https://docs.tulipfarm.site/self-hosting/when-install-fails#readyz