Docs

Working on the code

DocsWorking on the code

Environment variables

16 sections28 min readdocs/env.md

On this page16 sections
  1. 1The files
  2. 2Required to boot
  3. 3Session and link keys
  4. 4Datastores
  5. 5Search
  6. 6Object storage
  7. 7Database backups
  8. 8Email and SMS
  9. 9AI assistant keys
  10. 10Storefront links
  11. 11Tiers you can split out
  12. 12Networking
  13. 13Observability and logs
  14. 14Rate limiting and login lockout
  15. 15Feature switches
  16. 16Image tags

You should not need to read this to get started. skelet setup asks what you want and writes these files for you, secrets included - see docs/workflow.md. Come here when you want to change something afterwards, or to understand a variable the wizard did not ask about.

Each file is a .example you copy and fill in; the copies are gitignored.

The examples stay deliberately thin - variable names, a default, and at most a one-line hint. Everything that needs a paragraph lives here.

The files

FileCopy toRead by
../.env.example../.envdocker compose itself (prod and small) - see below
go.env.examplego.envcore in dev (app, worker, media, migrate)
signals.env.examplesignals.envthe Signals processes in dev (signals, ingest) - skelet up writes it and copies CUSTOMER_JWT_PUBLIC_KEY / EMAIL_LINK_SECRET / SIGNALS_SERVICE_SECRET over from go.env, and generates its own SIGNALS_ENCRYPTION_KEY
db.env.exampledb.envthe dev Postgres container
prod.env.exampleprod.envapp, worker, media, signals, ingest, migrate - one shared file
prod-signals.env.exampleprod-signals.envsignals only, on top of prod.env - the retention archive’s bucket and CORE_FRONT_API_URL; optional
prod-nextjs-vetrina.env.exampleprod-nextjs-vetrina.envthe storefront container
small.env.examplesmall.envthe single app container in the small profile
small-nextjs-vetrina.env.examplesmall-nextjs-vetrina.envits storefront container
prod-caddy.env.exampleprod-caddy.envthe opt-in Caddy edge (--profile edge), in both prod and small

The two small.* files belong to docker-compose.small.yml, the minimum deployable tenant (one app container, one Postgres, no Valkey / NATS / ClickHouse / OpenSearch). They follow the same split as the prod pair: secrets and DSNs come from the root .env, everything else from the file.

Why the root .env exists

Compose interpolates ${VAR} from the root .env (or the shell) and from nowhere else - never from an env_file. That’s the whole reason it’s a separate file.

Passwords are the one thing that has to appear twice: once on the datastore container, once inside the URL that connects to it. Putting them in the root .env lets compose derive both from a single value, so they can’t drift. A file per datastore would mean keeping each password in step with a DSN in prod.env by hand, which is the failure this avoids.

The split is: secrets in the root .env, everything else in env/prod.env.

Each URL stays overridable in full. Set DATABASE_URL in the root .env and it wins over the derived one, so a managed or external datastore is a one-line change and the matching *_PASSWORD just goes unused.

The two profile-gated passwords work as switches: set NATS_PASSWORD and SIGNALS_QUEUE_URL derives itself, leave it unset and the queue stays off. Same for CLICKHOUSE_PASSWORD. They can’t use compose’s “required” marker, because compose interpolates every service whether or not its profile is active - a missing value would break up for everyone not running that profile. The check lives in the container’s start command instead, so it fires only when the profile is actually on.

The storefront still keeps its own file. That’s a security boundary, not duplication: the most internet-exposed container holds no DB password, S3 key or SMTP credential, and no session key either. The two values it shares with the backend (REVIEW_VOTER_TOKEN_SECRET, STOREFRONT_REVALIDATE_SECRET) come from the root .env via compose, so they aren’t written twice either.

One gotcha on a machine that runs both stacks: the root .env is shared between docker-compose.yml and docker-compose.prod.yml, so dev values there apply to prod commands too.

Dev host ports

The dev stack publishes its datastores on 127.0.0.1 only, and nothing on the host dials them - skelet psql goes through docker compose exec. So each one can move when something else already holds its port, most often a local Postgres on 5432:

VarDefaultService
DB_HOST_PORT5432commerce Postgres
SIGNALS_DB_HOST_PORT5433Signals Postgres
VALKEY_HOST_PORT6379Valkey
CLICKHOUSE_HOST_PORT / CLICKHOUSE_HTTP_HOST_PORT9000 / 8123ClickHouse native / HTTP
OPENSEARCH_HOST_PORT9200OpenSearch
NATS_HOST_PORT4222NATS
SIGNALS_HOST_PORT8090Signals health
GARAGE_ADMIN_HOST_PORT9903Garage admin API

skelet up (and so skelet setup) probes every port it’s about to publish before it starts anything. On a terminal it asks, port by port, whether you’ve stopped the other thing - it checks again, and asks again if you haven’t - or offers to move a datastore to a free port and writes the var above into the root .env. Without a terminal it lists the clashes and exits. The app-facing ports (8080, 5173, 3000, 9900, 9902) stay fixed, because they’re part of the origins and CSP the apps are built around, so for those the only answer is to free them.

skelet up also builds on every run (docker compose up --build). When nothing changed that’s a cache hit, and it’s what keeps an image from an older checkout from running on after a pull changed its Dockerfile or go.mod - the project name is pinned, so another clone’s image counts.

prod.env is shared by every backend role, which is why role-specific settings belong in the compose service and not in here - the app has to stay a server. That’s SKELET_ROLE (worker, default server) and WORKER_PORT (8081, its /healthz).

Prod boot fails while a REQUIRED var is blank. That’s deliberate - a silent default in production is worse than a refusal to start.

Required to boot

VarNotes
ADMIN_JWT_SECRETopenssl rand -hex 32, in the root .env. Signs admin sessions, and only core holds it. See Session and link keys.
CUSTOMER_JWT_PRIVATE_KEY / CUSTOMER_JWT_PUBLIC_KEYAn Ed25519 pair, base64, in the root .env - skelet setup generates it, or skelet cli keys customer prints one. Core signs customer sessions with the private key; the Signals processes get the public key and can check a session but never mint one.
EMAIL_LINK_SECRETopenssl rand -hex 32, in the root .env. Signs the unsubscribe, open and click links, shared by core and the Signals processes.
REVIEW_VOTER_TOKEN_SECRETopenssl rand -hex 32, in the root .env. Signs anonymous review votes, shared by core and the storefront.
SIGNALS_SERVICE_SECRETopenssl rand -hex 32, in the root .env. Core signs every call on the Signals wire contract with it and the suite verifies with the same value - core and the Signals processes alone.
CONFIG_ENCRYPTION_KEYopenssl rand -base64 32, in the root .env. Keep it stable - rotating it orphans every stored secret.
SIGNALS_ENCRYPTION_KEYopenssl rand -base64 32, in the root .env, and different from CONFIG_ENCRYPTION_KEY. The signals process seals the destination secrets with it (webhook signing keys, the GA4 API secret, the Meta access token). Keep it stable for the same reason. Dev without it stores them in plaintext.
CORS_ALLOWED_ORIGINSStorefront + dashboard origins, comma-separated. Empty in dev means localhost defaults.
DB_PASSWORDCommerce Postgres. DATABASE_URL derives from it, or set that directly for an external database.
SIGNALS_DB_PASSWORDSignals Postgres, same deal via SIGNALS_DB_URL. Only the Signals processes read it - see below.

Production refuses any of these empty, short, a dev default, or equal to another one. JWT_SECRET is retired: every process refuses to boot while it is still set, since it used to sign both session types and a leftover value would read as if it still meant something.

Media storage config (S3_BUCKET_NAME, AWS_REGION) is also checked at boot. The bucket doesn’t have to be reachable for the app to start, but media breaks the moment anyone uploads or views an image.

Each key signs one thing and lives only where that thing is checked, so a compromised tier can forge only what it already holds:

KeySignsCoreSignals (signals)Beacon tier (ingest)Storefront
ADMIN_JWT_SECRETadmin sessionssigns, verifies---
CUSTOMER_JWT_PRIVATE_KEYcustomer sessionssigns---
CUSTOMER_JWT_PUBLIC_KEY-checks it pairsverifiesverifies-
EMAIL_LINK_SECRETemail linksverifies unsubscribesigns, verifies--
REVIEW_VOTER_TOKEN_SECRETreview votesverifies--signs
SIGNALS_SERVICE_SECRETcontract callssignsverifies--

Admin and customer tokens also carry different audiences and algorithms (HS256 and EdDSA), so neither can stand in for the other even if a deploy got the keys wrong.

Rotating a key doesn’t have to sign anyone out. Every token names the key it was signed with, and each key has a _PREVIOUS variable - a comma-separated list that keeps verifying while its tokens run out:

  1. Put the current value in the _PREVIOUS variable and a fresh one in the key itself. For the customer pair that means the old public key goes into CUSTOMER_JWT_PUBLIC_KEY_PREVIOUS, which core and both Signals tiers read.
  2. Restart. New sessions and links use the new key, old ones still verify.
  3. Once the old tokens have expired, clear _PREVIOUS. Sessions last 15 minutes and refresh, so a day is plenty; email links live in inboxes, so give EMAIL_LINK_SECRET_PREVIOUS as long as an unsubscribe link should work.

SIGNALS_SERVICE_SECRET rotates the same way, with SIGNALS_SERVICE_SECRET_PREVIOUS on both core and the Signals processes, so the two can restart one at a time instead of in lockstep. Its tokens last two minutes, so the previous value can go once both sides run the new one.

The Signals connection

Core calls the suite over gRPC in plaintext. Every call carries a short-lived bearer token and customer data, so keep the link on a network nothing else can see: one compose network on one host is that by default. When core and Signals run on different machines, encrypt the network underneath - a WireGuard or Tailscale overlay between the hosts, or a service mesh on Kubernetes - rather than leaving the traffic on a shared or public network.

SIGNALS_GRPC_ADDR can name several replicas behind one DNS name: core resolves it and spreads calls across every address it answers with.

Datastores

Commerce Postgres

DATABASE_URL is the source of truth for everything except Signals.

The bundled db service sits on the trusted compose network, so sslmode=disable plus ALLOW_INSECURE_DB=1 is accepted. Point at a managed or remote Postgres and you should drop ALLOW_INSECURE_DB and use sslmode=require (or verify-full) - the backend refuses to boot on sslmode=disable without the escape hatch.

DATABASE_REPLICA_URL gives analytics reads their own small pool. Point it at a read replica to keep reporting off the primary.

Pool sizes: DB_MAX_CONNS (25) and ANALYTICS_DB_MAX_CONNS (8).

Signals Postgres

SIGNALS_DB_URL holds the event spine, Lens, identities and Pulse - campaigns, flows and the email builder. It is the Signals processes’ database: signals and ingest open it, and app and worker never do - they reach everything in it over the wire contract.

It does not need a separate server. A second database on the commerce Postgres instance works exactly the same, which is the usual answer for small deployments - a CREATE DATABASE, not another container.

SIGNALS_GRPC_ADDR is where cmd/signals serves the wire contract core calls (signals:8092 in compose). It is core’s only address for Signals, and leaving it unset means core makes no calls at all - the admin chat then drops its Signals tools rather than offering ones that answer “unreachable”. With it set, SIGNALS_SERVICE_SECRET has to be too, or core refuses to boot rather than sign calls the suite would reject.

Every call core makes carries a deadline - ten seconds unless the caller set a shorter one, thirty minutes for the maintenance calls (a dump, a restore, a roster reconcile) - so a hung suite cannot hang the request that asked, a password reset most of all. When OpenTelemetry is on (OTEL_*), the trace started at core’s HTTP edge continues into the suite’s handler.

SIGNALS_REPLICA_URL is the suite’s read pool, bulkheaded off the ingest writes. Unset means the Signals primary. It is NOT DATABASE_REPLICA_URL

  • that’s the commerce replica and doesn’t hold spine tables.

CORE_FRONT_API_URL is where the suite reads core’s public storefront API - http://app:8080/api/front/graphql in compose, set in signals.env (dev) or prod-signals.env (prod). The email builder’s products block prices and links products the way the storefront does, and this is the one call the suite makes into core. Unset, the block renders nothing and every other email renders as before.

Connection pooler

Prod compose runs pgbouncer in front of each Postgres (edoburu/pgbouncer, which listens on 5432 unless LISTEN_PORT says otherwise - the compose pins 6432), one instance per database so the bulkhead survives the pooler. It exists because server connections are otherwise replicas x app pool: stock Postgres allows 100, and app + worker + ingest + media already reserve ~93 before a second replica exists.

Behind it, the app-side pools become client slots that queue in pgbouncer’s memory, and DEFAULT_POOL_SIZE (20) is what actually reaches Postgres. Size that one to the database’s cores, not to traffic - past roughly a dozen concurrent statements a 2-CPU Postgres context-switches more than it works.

POOL_MODE=transaction is what makes any of that true, and it has three consequences worth knowing:

  • Migrations go through it, one transaction per file. Each file runs and is recorded in a single transaction, and the runner resets the session before it commits, so a migration’s SETs never reach another client’s connection.
  • Server-side prepared statements are off. The pooled DSNs carry default_query_exec_mode=exec, because a PREPARE lands on whichever server connection is free and the next EXECUTE may not find it. pgbouncer 1.21+ can track them itself via max_prepared_statements; set that and the parameter can go.

Setting DATABASE_URL or SIGNALS_DB_URL outright still wins over all of it, which is how you point at a managed datastore - the pooler then has nothing to do and you can drop its two services.

Pool sizes: SIGNALS_DB_MAX_CONNS (8) and SIGNALS_READ_DB_MAX_CONNS (8) in the signals process; the ingest tier has its own, INGEST_SIGNALS_MAX_CONNS (16), because beacons write inside the request handler and it holds a connection per concurrent write.

Budget them against the server, not appetite. Stock Postgres allows 100 connections; app and worker hold 16 each, the signals worker another 16, so one ingest replica at 16 leaves room for a second. Beyond that, raise max_connections and the database’s CPU first, or put the ingest queue in front - it lands a whole batch as one statement, which is the actual fix for beacon volume. More connections against a 2-core Postgres move the wait rather than remove it.

Valkey

VALKEY_URL is OPTIONAL. Leave it empty to run Postgres-only, which is a reasonable choice for single-node and small deployments. Everything keeps working, with these tradeoffs:

AreaWithout Valkey
cachesno-op, so every read hits Postgres (slower, still correct)
rate limitsenforced per process, so the real budget is rate x replicas (when enabled at all - see below)
login lockoutper process, same caveat - still enforced, not bypassed
auth tokensrefresh / verification / reset move to Postgres (auth_tokens + auth_refresh_tokens, swept hourly by the auth-token-prune job)
notificationsreach the replica that created them, not all replicas
draft previewsUNAVAILABLE - the only feature that hard-requires Valkey

A malformed URL is still a boot error. That’s a typo, not a choice.

Multi-replica deployments should keep Valkey: per-process rate limits and single-replica notification fan-out both get weaker as you scale out.

In prod, set VALKEY_PASSWORD in the root .env and VALKEY_URL derives from it. To run Postgres-only, set VALKEY_URL= explicitly - that wins over the derived value.

VALKEY_CACHE_URL is OPTIONAL and points the catalog/CMS read caches and the storefront response cache at a different instance from the auth primary. Leave it empty and they share VALKEY_URL. The response cache is keyed per channel and locale, so its footprint grows with the channel count: the bundled valkey-cache takes VALKEY_CACHE_MAXMEMORY (default 512mb) under a VALKEY_CACHE_MEMORY_LIMIT container cap (default 640m), and a many-channel install should raise both.

VALKEY_LIMITS_URL is OPTIONAL and holds the rate-limit counters and login lockouts. The prod stack points it at a small bundled valkey-limits (volatile-ttl), so a flood on the evictable cache can’t evict the counters and quietly lift every limit. Leave it empty and the counters share the cache instance; set it to VALKEY_URL and they share the primary. The Signals process reads its caches off the same variable, and reads the killed-session list core publishes off VALKEY_URL, so a logout refuses a still-valid service token on the contract too. Without a VALKEY_URL that check is skipped, as it is on core’s own surface.

ClickHouse (the spine)

The event spine, analytics_events and analytics_identities, lives in ClickHouse, and the Lens heatmap grid derives from those raw events. The Signals processes refuse to boot without SIGNALS_CH_URL. Sessions, replay snapshots, rollups and everything Pulse owns stay in the Signals Postgres.

In dev the server and its schema come up inside the signals compose profile, so skelet up starts them whenever SIGNALS_ENABLED is on, and skelet doctor fails if the suite is on and either the container or signals.analytics_events is missing.

The spine and its migrations share one URL, SIGNALS_CH_URL - dbmate reads the same clickhouse://user:password@host:port/db the app does.

Set CLICKHOUSE_PASSWORD in the root .env; the server credentials and the ClickHouse URL derive from it. Backing the spine up is BACKUP DATABASE on a schedule of your own, see backups.md.

The server is generic infrastructure, so each consumer gets its own database. The compose file creates signals at first boot because the spine is the first consumer. A second consumer (a log collector, say) gets a CREATE DATABASE of its own.

Don’t add CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT - the env-created user already has the DDL rights the Signals driver needs, and that flag grants user administration on top. Bringing your own RBAC-managed user? Beyond SELECT/INSERT on its database the spine needs CREATE DICTIONARY, SYSTEM RELOAD DICTIONARY, ALTER DELETE (GDPR erasure), ALTER (retention partition drops), dictGet (every read resolves identity through the dictionary), CREATE TEMPORARY TABLE (segment members ride an external table), and SELECT on system.parts (the retention sweep sizes partitions before dropping them). A minimally-granted user passes boot and then fails at runtime, so grant the whole list.

NATS / JetStream (optional ingest queue)

Run with --profile queue and set SIGNALS_QUEUE_URL. Spine appends then land on disk before the request returns, and the signals worker (REQUIRED with this on) drains them into the engine. Unset means the direct spine path.

Set NATS_PASSWORD in the root .env; the server credentials and SIGNALS_QUEUE_URL both derive from it.

OPENSEARCH_URL is optional and prod compose ships no OpenSearch service.

Leaving it empty does more than disable the search box. Browse is the only product-listing path, so every category, collection and PLP falls back to the Postgres tier: ts_rank relevance instead of a tuned score, no stemming, no merch boosts, and facets only on selectable attributes (scalar ones like brand simply don’t appear).

That tier is a real floor, not a stub - a downed cluster gives you a slower store rather than an empty one. But it’s meaningfully slower on large catalogues, especially filtered browse. Point this at a cluster when the catalogue grows.

Managed clusters need OPENSEARCH_USERNAME / OPENSEARCH_PASSWORD. A private CA needs OPENSEARCH_CA_CERT_FILE.

Object storage

Required for media to work. STORAGE_DRIVER picks where files live: s3 (the default when it’s empty) for S3 or anything that speaks it, or filesystem for a directory on the box.

Browsers upload straight to the store rather than through the API, and the dashboard, storefront and edge CSPs only allow connections to their own origin. So in production set UPLOAD_ORIGINS in the root .env to the host uploads go to - the S3 presign host (on AWS, https://<bucket>.s3.<region>.amazonaws.com), or STORAGE_UPLOAD_URL on the filesystem driver. Space-separate several. The dashboard and Caddy read it when they start; the storefront bakes it in at build, so skelet release passes it through. Without it uploads fail with a CSP error in the browser console. Dev needs nothing - its local hosts are already allowed.

S3

Two shapes:

  • Real S3 / managed - endpoints empty, S3_USE_PATH_STYLE=false.
  • Garage / MinIO - both endpoints set, S3_PUBLIC_URL_BASE pointing at the web-endpoint vhost, S3_USE_PATH_STYLE=true.

S3_PRESIGN_ENDPOINT_URL is the browser-facing presign endpoint and falls back to S3_ENDPOINT_URL. STORAGE_MAX_UPLOAD_BYTES defaults to 10 MB, on either driver.

Filesystem

Every key is a file under STORAGE_DIR, and core serves them itself at /files/<key>. A browser upload is a PUT to that same path with a signed ?t= token, just like a presigned S3 URL. That makes it the simplest thing for a single server, and the wrong thing for more than one, since every process that touches files has to see the same directory.

  • STORAGE_DIR - the directory. Created on boot if it’s missing.
  • STORAGE_UPLOAD_URL - core’s public origin, e.g. https://api.shop.com. Browsers upload to it, and media URLs default to <it>/files/<key>. Without it browsers can’t upload, and image links only work on the API’s own host.
  • STORAGE_PUBLIC_URL - OPTIONAL. The base media URLs are built from, when something other than core serves the directory: nginx, Caddy or a CDN in front of it, e.g. https://cdn.shop.com for https://cdn.shop.com/<key>. Uploads still go to core. Whatever serves it should send the same headers core does (below).
  • STORAGE_SIGNING_KEY - signs the upload URLs the way S3 signs a presigned PUT: one key, one content type, one exact size, fifteen minutes. It lives in the root .env, skelet setup generates it, and production refuses to boot without a strong one. Dev falls back to a fixed key.

Every process that touches files reads the directory, so in the prod shape mount the same volume at /data/media on app, worker and media, plus signals and ingest for Lens recordings (the images create it owned by the app user):

# docker-compose.override.yml
services:
  app:    { volumes: ["media-files:/data/media"] }
  worker: { volumes: ["media-files:/data/media"] }
  media:  { volumes: ["media-files:/data/media"] }
  signals: { volumes: ["media-files:/data/media"] }
  ingest:  { volumes: ["media-files:/data/media"] }
volumes:
  media-files:

Files are served with nosniff and a sandboxing CSP, and anything that isn’t an image, video, audio or PDF downloads rather than rendering - they share the API’s origin, so nothing uploaded may run there.

Signals reads the same variables. Its Lens recordings go in core’s directory unless LENS_STORAGE_DIR gives them their own, which keeps them off the public /files/ route - the filesystem twin of LENS_S3_BUCKET. The database backup archive stays S3-only.

In dev, skelet up points all of it at the Garage sidecar, so leave it blank in go.env.

Self-hosting object storage on the same box is fine for small single-server deployments, but its volume is NOT in pg_dump - back it up separately or a disk loss takes every product image with it.

MEDIA_LIBRARY_ROOT_PREFIX is OPTIONAL and scopes the media tier’s library lookups to one key prefix, for a bucket shared with something else. Empty means the whole bucket, minus the prefixes the platform writes itself (catalog/, webforms/, data/, derived/) - the library won’t list, upload into, rename or delete those, and won’t delete its own root.

Moving between stores, skelet media-migrate reads the storage vars above as the source and takes the destination from its own flags - --dest-dir for a directory, --dest-bucket and friends for S3 - falling back to DEST_AWS_ACCESS_KEY_ID / DEST_AWS_SECRET_ACCESS_KEY when the flags are omitted. Neither is needed in normal operation.

Database backups

Environment-only by design: a backup destination editable at runtime would let anyone with settings access redirect a full-database dump to a bucket they control, past every permission scope. So there is no admin config for this - only BACKUP_* env vars, readable solely by whoever controls the deployment.

They go in prod-signals.env, which only the signals service loads, so the archive credential never reaches the commerce containers.

  • BACKUP_S3_BUCKET - the analytics retention archive’s destination, not a database backup. skelet takes no backups; see backups.md for how to set those up at the infrastructure level. Use a DEDICATED bucket and credential, not the media ones.
  • BACKUP_S3_ENDPOINT_URL, BACKUP_S3_REGION, BACKUP_S3_PATH_STYLE, BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEY, BACKUP_S3_PREFIX (default db/) - same shape rules as the media store above.

What lands there: with the signals/analytics/retention_action setting on archive, the daily retention jobs export aged spine events and send-log months as identity-stripped .jsonl.gz files under <prefix>archive/ before deleting the rows. With no BACKUP_S3_BUCKET the sweep fails closed - it deletes nothing rather than archiving nowhere.

Lens session recordings and heatmap page snapshots go to the media bucket by default, and that bucket is usually served publicly. Their object keys are random, so nobody can guess one, but LENS_S3_BUCKET moves them to a private bucket of their own on the same endpoint and credentials, which keeps them off the public endpoint entirely. Set it on both the signals and ingest services; production logs a warning while Lens shares the public bucket.

Email and SMS

SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD. In dev an unset SMTP_HOST prints emails to stdout; with the optional mailpit dev tool picked, skelet up points these at it. Two processes send through them: app its own transactional mail, and signals every campaign and flow mail - both read the same file, so one relay serves the install.

EMAIL_WEBHOOK_TOKEN is the bearer token for the ESP bounce webhook at POST /webhooks/email/events. Unset means that route 404s.

Twilio (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM) backs the Pulse send_sms node, read by the signals process. From is E.164.

AI assistant keys

The provider and key normally live in settings (ai/llm/provider, ai/llm/api_key - see settings.md), because an operator can change them without a deploy. The env vars are the fallback for when you would rather keep the key out of the database, and they are read in this order:

  1. the ai/llm/api_key setting
  2. AI_API_KEY - provider-agnostic, wins over both below
  3. OPENAI_API_KEY or ANTHROPIC_API_KEY, whichever matches the configured provider (anthropic is the default)

All three are OPTIONAL. With no key from any source the assistant stays off.

STOREFRONT_URL is where customer email links land, DASHBOARD_URL where admin password-reset links land. Include the scheme.

Cache purges (CMS publish, product and catalog edits, the cache screen) are signed with STOREFRONT_REVALIDATE_SECRET, which matches the storefront’s own file. With STOREFRONT_REVALIDATE_URL set, that one storefront serves every channel and gets a single request naming all the active ones - the usual setup, with the in-network URL. Leave it empty and each active channel is purged at its own public URL plus /api/revalidate, for channels served by separate deployments; a channel with no public URL, or one on localhost, is then not purged at all.

On the storefront side, one deployment serves as many channels as you point at it. STOREFRONT_CHANNEL_HOSTS maps hosts to channel uuids (shop.example.com=<uuid>,de.shop.example.com=<uuid>, admin > channels shows the ids), and STOREFRONT_CHANNEL_ID is the channel for any host the map doesn’t name. Set only the id and every host serves that one channel. A page request on a host neither covers is a 404, and so are the sitemaps and robots.txt. Absolute URLs (canonicals, sitemap, JSON-LD) use the channel’s public URL, with STOREFRONT_BASE_URL as the fallback for a channel without one. Neither is needed at build time: the image is the same for every channel. REVIEW_VOTER_TOKEN_SECRET signs review-voter tokens and voting 500s without it - it comes from the root .env, matching core’s.

BACKEND_INTERNAL_URL is where the storefront reaches the backend, and it’s read at runtime too, so compose sets it on the container rather than the build. Besides GraphQL it carries the backend links the browser loads from the shop’s own origin: /media/t/* thumbnails, /download/*, /unsubscribe/*, the /subscribe/confirm/* signup confirmations, the /e/* email pixels and clicks, and the /t/* beacons. Left unset it falls back to http://localhost:8080, which only fits pnpm run dev on the host.

A purge reaches one storefront process, since the page cache lives in it. Run a single replica unless you’ve configured a shared cache handler; see deploying.md.

Tiers you can split out

All of these are the same binary or an additive service. Everything works in one process; splitting buys isolation.

TierSwitchWhat it does
workerJOBS_EXTERNAL=trueApp stops claiming scheduler jobs, webhook deliveries and outbox rows, leaving them to the worker.
signalsSIGNALS_ENABLED=true, prod shape onlyServes the suite’s own admin API on :8090 and its wire contract on :8092, and performs the retention, forwarding and trait-compute work core’s scheduler asks for.
ingestroute /t/* and /e/*Serves the public surfaces - the beacons and the mail tracking routes - on 127.0.0.1:8091.
mediaroute /media/t/*Image resize + focal crop, so decode CPU doesn’t land next to checkout.

JOBS_EXTERNAL is read by the app AND the worker, so one setting configures both - the worker ignores the flag and always runs its layer. It’s all DB-claim based, so leaving the flag false while a worker runs is safe (both claim, work is partitioned), it just doesn’t isolate anything. Setting it true with NO worker running stops that background work entirely.

The Signals tier needs no such switch: core’s scheduler owns the cadence of every Signals job and the suite performs the work over the contract, so one owner schedules and there is nothing to double-run.

INGEST_FALLBACK_URL points the ingest tier back at the app for the two routes it can’t serve alone - form submissions and back-in-stock signups, which need the app’s subscriber graph. Leave it unset and those two paths 404, which the browser SDKs fail soft on; everything else on /t/* is unaffected.

SIGNALS_INGEST_URL is where app proxies /t/*, the beacon surface being the suite’s to serve - http://signals:8090 by default. Unset, beacons answer as they do with the suite switched off: a silent 204 on the POSTs and a 404 on the SDK reads. The commerce beacons (/t/forms*, /t/back-in-stock) are app’s own and are unaffected either way.

SIGNALS_URL is where app proxies /e/* - the open pixel and the click redirect in every marketing mail, recorded against the send log they name. http://signals:8090 by default. The ingest tier serves the same routes, so once /t points at a scaled-out ingest, /e can follow it: the two variables are separate only so they need not move together. Unset, opens go unrecorded and links in already-sent mail 404.

Two parity notes when /t routes to the ingest tier:

  • GEOIP_DB_PATH is shared through prod.env, but the .mmdb file has to be mounted into the ingest container too, or country attribution silently stops for beacon traffic.
  • TRUSTED_PROXIES must cover the ingest containers (the default 172.16.0.0/12 does). Otherwise the app sees them as the client on the proxied forms and back-in-stock paths, and its per-IP rate limits collapse into one bucket.

/media/t/* is served by app regardless, so routing it is a scaling choice.

Media tier knobs: MEDIA_PORT (8083), MEDIA_MAX_SOURCE_PIXELS (~40 MP - uploads go straight to object storage via presigned PUT so nothing else bounds their dimensions, and decode allocates roughly wh4 bytes no matter how small the output; over the cap is a 422, not a retry), MEDIA_RENDER_SLOTS (cmd/server defaults to half the CPUs since it has requests to protect, the media tier to all of them; callers queue ~3s for a slot then get a 503 with Retry-After, so a burst throttles before it refuses), and MEDIA_DB_MAX_CONNS (focal points come from the media library, so the tier wants DATABASE_URL; it runs without one, cover crops just centre instead).

Worth knowing: warm derivatives are answered with a 302 to S3_PUBLIC_URL_BASE so image bytes never pass through Go. With that unset every hit gets proxied instead, and the tier buys you much less.

Networking

TRUSTED_PROXIES is the list of proxy IPs and CIDRs whose X-Forwarded-For is trusted. Empty means no proxy. Without it, per-IP rate limits share one bucket.

ALLOW_PRIVATE_WEBHOOK_TARGETS=true bypasses the SSRF guard for webhook/destination targets on private IPs. One policy for cmd/server and the signals worker; unset means off outside development. Only for receivers you host on the compose network.

SEO_CRAWL_BYPASS_TOKEN is OPTIONAL and is sent by the link-map crawler on every request so a storefront behind basic auth, a staging password or a maintenance gate still answers it. Empty means the crawler gets whatever an anonymous visitor gets.

SEO_CRAWL_ALLOWED_ORIGINS is OPTIONAL, a comma-separated list of extra origins the link-map crawl may start at, like http://nextjs-vetrina:3000 when the storefront is reached on its compose address. Without it a crawl only starts at the channel’s public URL or STOREFRONT_URL - the crawl reads whatever answers and carries the bypass token, so it can’t be pointed anywhere else.

PORT overrides the port a process listens on. Each service has its own default (8080 for the app), so you only need this when running one outside compose.

Observability and logs

SENTRY_DSN and the OTel vars are optional - blank means logs to stdout. With the optional grafana dev tool picked, skelet up points them at http://grafana:4318. APP_VERSION is the release tag for Sentry and OTel; CI should inject the git sha.

Application logs go to stdout as JSON (text when APP_ENV != production), and the compose files cap them at 10 MB x 3 files per service so they can’t fill the disk. That cap is also the retention: rotated-out lines are GONE. If you need searchable history, point a collector (Vector, Fluent Bit, Grafana Alloy) at the container log files or swap the logging driver.

OTLP carries traces and metrics only. It does NOT ship logs.

LOG_LEVEL takes debug | info | warn | error.

OTEL_SERVICE_NAME overrides the identity a process reports to OTel. Sibling processes - cmd/signals, cmd/ingest, cmd/media - set it so their traces don’t all land under the app’s name. Unset falls back to the built-in default.

PPROF_ENABLED=true mounts /debug/pprof on the service’s own mux. Off by default because these ports are published in dev, and worth switching on only for as long as you are profiling.

Rate limiting and login lockout

Both ship off. A fresh install shouldn’t hand out 429s while you click around, and a dev box has no proxy in front of it for per-IP keying to mean much anyway.

RATE_LIMIT_ENABLED=true

That one flag turns on every per-IP limiter (admin GraphQL, storefront GraphQL, plugin HTTP routes, media transforms, download/unsubscribe/email tokens, the /t/* beacons) and the failed-login lockout. In production the boot log warns while it’s off, since a public deployment with no per-IP budget is one scraper away from trouble. It’s a warning and not a refusal because your proxy or CDN may already be doing the metering.

Once it’s on, each surface has its own per-minute budget:

VarDefaultSurface
GENERAL_RATE_LIMIT_PER_MIN30admin + storefront resolver-level checks
AUTH_RATE_LIMIT_PER_MIN10login / register / password reset
FRONT_RATE_LIMIT_PER_MIN600the whole storefront GraphQL endpoint
CHECKOUT_RATE_LIMIT_PER_MIN120checkout step saves and checkout reads, each its own budget
MEDIA_TRANSFORM_RATE_LIMIT_PER_MIN120/media/t/*
TOKEN_RATE_LIMIT_PER_MIN60download, unsubscribe, signup confirmation, email open/click
INGEST_RATE_LIMIT_PER_MIN300/t/* beacons, per endpoint
MCP_RATE_LIMIT_PER_MIN120MCP tool calls, per integration token or admin (mcp.md)
SIGNUP_CONFIRM_RATE_LIMIT_PER_HOUR3signup-form confirmation emails, per address (an hourly budget, unlike the rest)
PAYMENT_METHOD_SAVE_RATE_LIMIT_PER_HOUR5saved-card attempts per customer, failures included (an hourly budget) - each is a gateway vault call, the card-testing surface

Setting one of these does NOT turn the layer on - RATE_LIMIT_ENABLED gates all of them, so a knob left over in an old env file can’t quietly start refusing traffic.

The lockout is separate, in case you want brute-force protection without per-IP budgets: LOGIN_LOCKOUT_ENABLED (defaults to whatever RATE_LIMIT_ENABLED is) and LOGIN_LOCKOUT_THRESHOLD (10 failures, then a 15 minute lock).

Two limits are per-feature and stay off by default on their own terms, independent of all of the above:

  • Catalog query throttle - Settings > General > Security, a per-IP soft budget for storefront catalog reads. Past the budget requests are slowed, past 3x refused. Meant to be flipped on mid-attack with no deploy, which is why RATE_LIMIT_ENABLED doesn’t gate it. 0 = off.
  • Per-plugin hook budget - plugin/<slug>/rate_limit_per_min in Settings, 0 = unlimited.

Feature switches

All default to true.

VarOff means
SIGNALS_ENABLEDno Signals suite (Pulse campaigns/flows/the email builder, Lens, Synapse, Prism, spine ingestion, forwarding). Transactional email keeps sending from core’s own templates. This is core’s switch: it decides whether core dials the suite at all. The Signals processes have none of their own, so a deployed suite is on and cannot quietly drop writes while core keeps sending them.
AI_ENABLEDno LLM integrations, including the Settings > AI section
MCP_ENABLEDno MCP endpoint for AI agents (mcp.md). It doesn’t use the store’s own AI provider, so AI_ENABLED doesn’t switch it off

GEOIP_DB_PATH points at a MaxMind GeoLite2 .mmdb for geo on web events. A bad path is logged, not fatal - geo just doesn’t appear.

REQUEST_MAX_BODY_BYTES defaults to 8 MB.

Image tags

APP_IMAGE, DASHBOARD_IMAGE and NEXTJS_VETRINA_IMAGE are set in the SHELL, not in an env file - compose interpolates them from the process env. skelet release prints the values after a push.


Adding or renaming a variable? Update this file too, and keep the .example line to a name, a default and at most one line of hint.