Local development
Every backing service the project needs runs locally from a single docker-compose.yml at the
repository root. Nothing in this stack requires cloud credentials.
Compose is the supported path. If the machine you are on has no Docker daemon — an unprivileged
container, a locked-down CI runner — scripts/native-services.sh installs and runs PostgreSQL, Redis,
MinIO and Mailpit as ordinary background processes on the same ports instead. See
running without Docker below.
Prerequisites
| Tool | Version | Notes |
|---|---|---|
| Node.js | >= 20 | Root package.json declares engines.node >= 20; Docker images use node:20-slim |
| pnpm | 10.33.4 | Pinned via packageManager; enable with corepack enable |
| Docker with Compose v2 | current | docker compose, not docker-compose. Optional if you use the native fallback |
sudo + Debian/Ubuntu | — | Only for the native fallback: it runs apt-get and su postgres, and downloads linux-amd64 binaries |
First run
git clone <repo> && cd <repo> # every path below is relative to this repository root
corepack enable
pnpm install
cp .env.example .env # then edit as needed — the defaults work as-is
docker compose up -d # start every backing service
pnpm db:migrate # apps/api: prisma migrate dev — creates the `app` schema
pnpm db:seed # 3 suppliers, 5 restaurants, 120 catalog items
pnpm db:migrate, db:deploy, db:seed and db:generate at the root are thin pass-throughs to
pnpm --filter @lucille/api <same script>; there is no root db:reset (see
migrations and seeds).
Then start the applications you are working on, either all at once through Turborepo or individually:
pnpm dev # turbo run dev — every app in parallel
pnpm --filter @lucille/api dev # tsx watch src/server.ts → :3000
pnpm --filter @lucille/worker dev # tsx watch src/worker.ts → :3001 health
pnpm --filter @lucille/frontend dev # vite → :5173
pnpm --filter @lucille/docs dev # docusaurus start → :3002
The compose file also contains api, worker, frontend and docs services built from the
repository's Dockerfiles. Use those to verify a production-shaped build; use the dev scripts above for
day-to-day work, because they have hot reload and the containers do not.
.envThere is no dotenv dependency and no --env-file flag anywhere in the workspace, and no application
module reads a .env file. The root .env is used by docker compose for variable interpolation
(ZITADEL_CLIENT_ID, ZITADEL_PROJECT_ID) and by the Prisma CLI, which loads a .env relative to its own
working directory. A pnpm dev process therefore sees only what your shell exports, and otherwise falls
back to the defaults baked into apps/api/src/config/index.ts. Those defaults already describe the compose
stack on localhost, which is why the API boots with no configuration at all — but a value you edited in
.env will not take effect until you export it:
set -a && . ./.env && set +a && pnpm dev
Service and port map
| Service | Image | Ports (host → container) | Credentials | Purpose |
|---|---|---|---|---|
postgres | postgres:16 | 5432:5432 | lucille / lucille, database lucille_order_center | Primary datastore. Also hosts Zitadel's own database |
redis | redis:7.2-alpine | 6379:6379 | none | BullMQ backend. Started with --appendonly no --save '' — a local queue does not need persistence |
minio | minio/minio | 9000:9000, 9001:9001 | minioadmin / minioadmin | S3-compatible stand-in for DigitalOcean Spaces. Console on 9001 |
mailpit | axllent/mailpit | 1025:1025, 8025:8025 | any (auth accept-any) | SMTP capture. Web UI on 8025 |
sftp | atmoz/sftp | 2222:22 | lucille / lucille | Sysco SFTP simulator with outbound, inbound and archive directories |
zitadel | ghcr.io/zitadel/zitadel:latest | 8080:8080 | admin@lucille.localhost / Password1! | OIDC provider, start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled |
api | built from apps/api/Dockerfile | 3000:3000 | — | Production-shaped API container |
worker | built from apps/api/Dockerfile | 3001:3001 | — | Same image, node apps/worker/dist/worker.js |
frontend | built from apps/frontend/Dockerfile | 5173:80 | — | nginx serving the built SPA |
docs | built from apps/docs/Dockerfile | 3002:80 | — | nginx serving this site |
Named volumes postgres-data, minio-data and sftp-data persist across restarts. To start completely
clean:
docker compose down -v && docker compose up -d
Postgres, Redis and MinIO each define a healthcheck (pg_isready, redis-cli ping, mc ready local).
The zitadel service waits on postgres: service_healthy; api and worker wait on
postgres: service_healthy and redis: service_healthy, and on minio, mailpit and sftp with
service_started only — so a cold docker compose up orders itself correctly for the two services where
ordering matters. Note that neither api nor worker declares a dependency on zitadel: the API boots
happily without a reachable identity provider and fails at the first token verification instead.
The containerised api and worker are configured with ZITADEL_ISSUER: http://zitadel:8080, while a
browser reaches the same instance at http://localhost:8080. That difference is deliberate but it is also
a trap — see troubleshooting.
Environment
Copy .env.example to .env. Every variable in it is optional as far as the schema in
apps/api/src/config/index.ts is concerned — that schema has a default for everything and no required
key, deliberately, so a partially configured environment can still boot, migrate and answer /healthz;
each integration validates its own credentials at the point of use. The variables that matter most:
DATABASE_URL=postgresql://lucille:lucille@localhost:5432/lucille_order_center?schema=app
REDIS_URL=redis://localhost:6379
ZITADEL_ISSUER=http://localhost:8080
ZITADEL_CLIENT_ID= # filled in after seeding Zitadel
ZITADEL_CLIENT_SECRET= # ditto — the app uses client_secret_basic
ZITADEL_PROJECT_ID= # filled in after seeding Zitadel
ZITADEL_REDIRECT_URI=http://localhost:3000/api/auth/callback
ZITADEL_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/login
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=lucille-po-pdfs
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_FORCE_PATH_STYLE=true # required for MinIO; false against Spaces
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_FROM_ADDRESS=orders@lucille.localhost
ADMIN_ALERT_EMAIL=ops@lucille.localhost
SYSCO_SFTP_HOST=localhost
SYSCO_SFTP_PORT=2222
SYSCO_SFTP_USERNAME=lucille
SYSCO_SFTP_PASSWORD=lucille # password auth is for the simulator only
SENTRY_DSN= # empty disables Sentry entirely
OTEL_ENABLED=false
Note the ?schema=app on DATABASE_URL: the application owns the app schema, mirroring the Chrono
postgres-db template's provisioning. Three variables deserve a comment:
SENTRY_DSN— leave it empty.initSentry()returns early when the DSN is falsy, so nothing is reported and no network calls are made. There is no local Sentry container; use a personal free-tier DSN if you need to exercise the integration.SYSCO_SFTP_PASSWORD— password authentication exists only for theatmoz/sftpsimulator. Production uses key authentication viaSYSCO_SFTP_KEY_B64, a base64-encoded PEM private key.AUTH_JWKS_URL— commented out in.env.exampleand meant to stay that way. It overrides the JWKS URL that is otherwise derived as${ZITADEL_ISSUER}/oauth/v2/keys, and exists so the API test harness can serve its own key set and exercise the productioncreateRemoteJWKSetpath verbatim.
The config layer also aliases the names the cluster injects onto the names the code reads —
DB_LUCILLE_API_URI → DATABASE_URL, REDIS_URI → REDIS_URL, SPACES_* → S3_* — because the Chrono
service templates cannot rename their output keys. An explicitly set canonical name always wins, so this
never overrides a local .env.
Database: migrations and seeds
pnpm db:migrate # prisma migrate dev — create and apply a new migration
pnpm db:deploy # prisma migrate deploy — apply committed migrations (what CI and the cluster run)
pnpm db:generate # prisma generate — regenerate the client after a schema edit
pnpm db:seed # tsx prisma/seed.ts
apps/api additionally defines db:reset (prisma migrate reset --force), reachable as
pnpm --filter @lucille/api db:reset. Be aware that Prisma 6.19 guards migrate reset against
non-interactive/agent invocation (decision D13 in IMPLEMENTATION.md), and the project does not bypass
that guard: if it refuses, drop and recreate the database yourself and run pnpm db:deploy instead. The
test harnesses truncate rather than reset for the same reason.
The seed is upsert-based and derives every id from a UUIDv5 namespace, so it is safe to re-run. It produces
three suppliers — one per dispatch channel (sysco, costco, centralKitchen) — five restaurant locations
(Downtown, Riverside, Midtown, Harborview, Uptown), four application users (one admin, two single-location
chefs, one chef assigned to two locations, which is what exercises multi-location scoping) and exactly 120
catalog items. Every catalog item gets a catalog_supplier_mappings row; 50 of the 120 also carry an
alternate supplier, which is what makes the back-order reroute path exercisable. There is no deliberately
unmapped item in the seed data — to exercise the "item has no mapping" rejection path, create an item
through the admin API and leave it unmapped, or soft-delete its mapping.
Compose runs any .sql in scripts/postgres-init/ on first initialisation of the data volume.
01-create-databases.sql creates two extra databases alongside the application's own
lucille_order_center (which the image creates from POSTGRES_DB): zitadel, for the identity provider,
and lucille_test, which is the database the API and worker test suites point at. Because the script only
runs on an empty volume, changing it — or picking up a change to it — requires docker compose down -v.
That also means the test suites need this stack running. Both apps/api/vitest.config.ts and
apps/worker/vitest.config.ts describe integration suites against the real PostgreSQL and Redis;
Prisma is never mocked. Both run pool: 'forks' with singleFork: true, and the API suite additionally
sets fileParallelism: false and takes a PostgreSQL session-level advisory lock in
apps/api/tests/global-setup.ts, so two concurrent runs queue instead of truncating the shared
lucille_test database out from under each other. See testing.
Zitadel: seeding with scripts/seed-zitadel.ts
Zitadel starts with start-from-init and bootstraps the lucille-org organisation with the admin user
from the compose environment (admin@lucille.localhost / Password1!). Everything the application itself
needs — the project, its roles, the OIDC client and the test users — is created by
scripts/seed-zitadel.ts.
Getting a token first
The script authenticates with a Personal Access Token for a machine user holding IAM_OWNER, read from
ZITADEL_ADMIN_PAT. It is the only truly required variable: with it unset the script prints the five steps
below and exits with status 64 before touching anything.
- Open
http://localhost:8080and log in as the first-instance admin. - Users → Service Users → New, name
seeder, Access Token Type Bearer. - Grant it
IAM_OWNERunder Organisation → Managers. - On that service user, Personal Access Tokens → New, and copy the token.
export ZITADEL_ADMIN_PAT=<token>and run the script.
The comment block at the top of the script mentions ZITADEL_ADMIN_USERNAME/ZITADEL_ADMIN_PASSWORD as an
alternative that mints a token interactively. That path is not implemented — the code reads only
ZITADEL_ADMIN_PAT. Treat the PAT as mandatory.
Running it
# From the repository root. The `--filter` sets the cwd to apps/api, hence the ../../ prefix.
ZITADEL_ADMIN_PAT=<token> pnpm --filter @lucille/api exec tsx ../../scripts/seed-zitadel.ts
Everything else has a default, and every override is read straight from the environment:
| Variable | Default | Effect |
|---|---|---|
ZITADEL_ADMIN_PAT | — (required) | Bearer token for every Management API call; trailing-slash-safe |
ZITADEL_ISSUER | http://localhost:8080 | Base URL of the instance; trailing slashes are stripped |
ZITADEL_ORG_NAME | lucille-org | Organisation to create or reuse |
ZITADEL_PROJECT_NAME | lucille-order-center | Project to create or reuse |
ZITADEL_APP_NAME | lucille-web-app | OIDC application to create or reuse |
API_ORIGIN | http://localhost:3000 | Redirect URI becomes ${API_ORIGIN}/api/auth/callback; also sets devMode |
FRONTEND_ORIGIN | http://localhost:5173 | Post-logout redirect becomes ${FRONTEND_ORIGIN}/login |
SEED_USER_PASSWORD | Password1! | Password for the four seeded humans — local only |
It first polls ${ZITADEL_ISSUER}/debug/healthz every two seconds for up to 120 seconds, so running it
immediately after docker compose up -d is fine. Then, in order, it:
- creates the organisation, and looks up its id;
- creates the project with
projectRoleAssertion: true,projectRoleCheck: false,hasProjectCheck: false, thenPUTs the project again unconditionally so role assertion is on even if the project already existed with it off; - creates the
chef("Chef") andadmin("Administrator") project roles, both in grouplucille; - creates the OIDC application: code flow plus refresh token,
OIDC_APP_TYPE_WEB,OIDC_AUTH_METHOD_TYPE_BASIC, JWT access tokens,accessTokenRoleAssertion,idTokenRoleAssertionandidTokenUserinfoAssertionalltrue, anddevModeon unlessAPI_ORIGINishttps://; - imports four human users with verified emails and no forced password change —
admin@lucille.example.com,chef.downtown@…,chef.riverside@…,chef.multi@…, which are exactly the addresses inapps/api/prisma/seed-constants.ts— and grants each its project role.
Finally it prints a paste-ready block of ZITADEL_ISSUER, ZITADEL_PROJECT_ID, ZITADEL_CLIENT_ID and
ZITADEL_CLIENT_SECRET, followed by the Zitadel subject ids of the four users. Copy those into .env and
restart the API. Note that client credentials are only printed the first time: Zitadel does not re-issue
them, so on a re-run the script tells you to read them from
${ZITADEL_ISSUER}/ui/console/projects/{projectId}/apps/{appId} instead.
What it is idempotent about
Every create is wrapped so that a 409 or an AlreadyExists body logs exists <thing> and continues,
which makes the whole script safe to re-run: org, project, both roles, the application, the four users and
their role grants. The project's projectRoleAssertion is re-asserted on every run. Two things are
not re-run: the client secret (never re-issued), and the application's own settings — because when the
app already exists the create is skipped and no update call is made.
Open the application in the console and enable Token Settings → "User Roles Inside Access Token":
http://localhost:8080/ui/console/projects/{projectId}/apps/{appId}
The script prints that exact URL at the end. This application-level toggle is not exposed on the OIDC
application API surface the script uses, and is not settable through the Zitadel custom resource either.
The script does send accessTokenRoleAssertion: true when it creates the application, and on a recent
Zitadel that is accepted — but it is a create-only field here, so on any re-run against an existing
application nothing sets it, and on an instance that ignores the field it was never set at all. Either way
the console click is the reliable answer.
Without it — and without the project-level "Assert Roles on Authentication", which the script does set —
Zitadel omits the urn:zitadel:iam:org:project:roles claim entirely, with no error. Verification then fails
in apps/api/src/auth/jwt.ts with 401 Access token carries no Lucille Order Center role. See
authentication and troubleshooting.
One last wrinkle the script itself flags: pnpm db:seed gives the application's users rows fixed
placeholder UUIDs, while the API mirrors the real Zitadel subject on first login. To keep the seeded
restaurant assignments, either re-point those rows at the subject ids the script printed, or assign chefs
with POST /api/admin/restaurants/:id/chefs after each has logged in once.
Mailpit — outbound email
Web UI at http://localhost:8025. Every message the worker sends to Costco or Central Kitchen lands
here, including the PDF attachment and the admin failure-alert emails. Mailpit accepts any credentials
and any sender, so the local relay never rejects a send.
Useful while testing: place an order containing items mapped to both email suppliers and confirm two separate messages arrive, each listing only its own supplier's lines.
MinIO — object storage
Console at http://localhost:9001, credentials minioadmin / minioadmin. The API calls
ensureBucketExists() during boot and creates lucille-po-pdfs if it is missing — but only when
NODE_ENV !== 'production', so in the cluster the bucket is expected to be provisioned by infrastructure.
Central Kitchen PDFs land at central-kitchen/{orderId}/{timestamp}.pdf.
Remember S3_FORCE_PATH_STYLE=true for MinIO and false for Spaces — MinIO does not do virtual-hosted
addressing on localhost.
The SFTP simulator
atmoz/sftp is started with lucille:lucille:::outbound,inbound,archive, which creates the user and the
three directories the Sysco integration expects.
# What has the worker dropped?
docker compose exec sftp ls -la /home/lucille/outbound
# Read a generated EDI file. The name is PO_{restaurantCode}_{orderNumber}_{yyyyMMddHHmmss}.edi
docker compose exec sftp cat /home/lucille/outbound/PO_DOWNTOWN_LOC-20260813-0007_20260813143200.edi
# Feed an acknowledgment back in to exercise the inbound path
docker compose cp apps/worker/tests/fixtures/855-backorder.edi sftp:/home/lucille/inbound/
# Confirm the poll archived what it processed
docker compose exec sftp ls -la /home/lucille/archive
Dropping a fixture into /inbound/ is how the whole inbound flow is tested end to end: the poll picks it
up, the parser interprets BAK and the ACK loops, line statuses change, a reroute is enqueued, and the
file moves to /archive/. 997 fixtures work the same way and are how the EDI_REJECTED path is
exercised.
Running without Docker: scripts/native-services.sh
Some environments have no Docker daemon at all — an unprivileged container, a locked-down CI runner, the
build pod this project was developed in (decision D1 in IMPLEMENTATION.md). For those,
scripts/native-services.sh installs the same programs from Debian packages and vendor release binaries and
runs them as ordinary background processes on the ports compose publishes. Everything then lives on
localhost rather than on compose service names, which is what the config defaults already assume.
sudo ./scripts/native-services.sh # no argument == start
sudo ./scripts/native-services.sh install # install packages/binaries only, start nothing
sudo ./scripts/native-services.sh start # install (idempotently), then start, then bootstrap, then status
sudo ./scripts/native-services.sh stop # stop all four
./scripts/native-services.sh status # one line per service: up / down
Those four subcommands are all it accepts. Anything else prints usage: … [install|start|stop|status] to
stderr and exits 64. There are no flags; the three knobs are environment variables:
| Variable | Default | Meaning |
|---|---|---|
PG_VERSION | 15 | Debian PostgreSQL major version — the package and the cluster |
MINIO_DATA | /var/lib/minio-data | MinIO's data directory, created if missing |
LOG_DIR | /var/log | Where minio.log and mailpit.log are written |
What install does
apt-get update then apt-get install -y --no-install-recommends postgresql-${PG_VERSION} postgresql-common redis-server curl ca-certificates, with DEBIAN_FRONTEND=noninteractive. MinIO and
Mailpit are not packaged, so if the binary is not already on PATH it is downloaded:
- MinIO →
https://dl.min.io/server/minio/release/linux-amd64/miniointo/usr/local/bin/minio - Mailpit → the
mailpit-linux-amd64.tar.gzof the latest GitHub release, extracted to/usr/local/bin/mailpit
Both URLs are hard-coded to linux-amd64. The script needs root (it runs apt-get, writes to
/usr/local/bin and uses su postgres) and outbound network access. set -euo pipefail is in force, so a
failed download or a failed apt-get aborts the whole run before anything starts.
What start does
There is no systemd in these environments, so each daemon is started with its own tooling rather than
systemctl:
| Service | How it is started | Port(s) |
|---|---|---|
| PostgreSQL | pg_ctlcluster ${PG_VERSION} main start — the ordinary Debian cluster, data under /var/lib/postgresql/15/main | 5432 |
| Redis | redis-server --daemonize yes --save '' --appendonly no — no config file, so stock defaults otherwise | 6379 |
| MinIO | nohup minio server ${MINIO_DATA} --address :9000 --console-address :9001, root user/password minioadmin | 9000, 9001 |
| Mailpit | nohup mailpit --smtp 0.0.0.0:1025 --listen 0.0.0.0:8025 --smtp-auth-accept-any --smtp-auth-allow-insecure | 1025, 8025 |
MinIO and Mailpit are only launched if pgrep -x does not already find them, so start is safe to repeat.
Their stdout and stderr go to ${LOG_DIR}/minio.log and ${LOG_DIR}/mailpit.log — those two files are the
only place a startup failure is recorded. The script writes no PID files of its own: it tracks MinIO and
Mailpit purely by process name with pgrep/pkill, and leaves Postgres and Redis to manage their own.
Rather than the sleep-free healthcheck graph compose has, start waits a flat 4 seconds and then
bootstraps the databases directly — scripts/postgres-init/*.sql is a compose entrypoint mount and is
not executed on this path. The equivalent work is done inline and conditionally:
- a
lucillerole with passwordlucilleandSUPERUSER, ifpg_rolesdoes not already have it; - the databases
lucille_order_center,lucille_testandzitadel, eachOWNER lucille, ifpg_databasedoes not already have them.
The app schema is not created here — prisma migrate deploy/dev creates it from the ?schema=app on
DATABASE_URL. Finally start calls status, which probes pg_isready -h 127.0.0.1, redis-cli ping,
http://127.0.0.1:9000/minio/health/live and http://127.0.0.1:8025/api/v1/messages.
stop is the mirror image: pg_ctlcluster … stop, redis-cli shutdown nosave, pkill -x minio,
pkill -x mailpit. Every one of those is || true, so stop always exits 0 even if nothing was running.
How it differs from the compose path
| Aspect | docker-compose.yml | native-services.sh |
|---|---|---|
| PostgreSQL | postgres:16 | postgresql-15 — what Debian bookworm ships |
| Redis | redis:7.2-alpine | Debian redis-server 7.x, started without a config file |
| MinIO / Mailpit | minio/minio, axllent/mailpit images | Latest upstream release binaries in /usr/local/bin |
| Zitadel | Included, on 8080 | Not covered — needs a full instance bootstrap |
| SFTP simulator | atmoz/sftp on 2222 | Not covered — worker tests use an in-process ssh2 server |
| Extra databases | scripts/postgres-init/01-create-databases.sql on an empty volume | Created inline by bootstrap_databases, on every start |
lucille role | Created by the image from POSTGRES_USER | Created as a superuser |
| Persistence | Named volumes; down -v wipes | System paths; nothing is wiped unless you delete them |
| Startup ordering | Healthchecks plus depends_on | A flat sleep 4 |
| Restart on failure | restart: unless-stopped | None — a crashed process stays dead until you re-run start |
| Hostnames | Service names inside the compose network | Everything on localhost |
Because Zitadel is absent, scripts/seed-zitadel.ts cannot be run on this path, and neither can a real
browser login. Token verification is instead exercised by the API test harness, which serves a local JWKS via
AUTH_JWKS_URL and signs RS256 tokens with genuine Zitadel claim shapes (decision D2) — the production
createRemoteJWKSet code path runs unmodified.
Repository-wide scripts
pnpm build # turbo run build
pnpm lint # turbo run lint
pnpm typecheck # turbo run typecheck
pnpm test # turbo run test
pnpm test:e2e # pnpm --filter @lucille/frontend test:e2e → playwright test
pnpm format # prettier --write "**/*.{ts,tsx,js,jsx,json,md,yml,yaml}"
pnpm format:check # prettier --check, same glob
pnpm clean # turbo run clean && rm -rf node_modules
build, lint, typecheck and test all declare dependsOn: ["^build"] in turbo.json, so
packages/types is always built before anything that consumes it; build caches dist/**, build/** and
.docusaurus/**, test caches coverage/**, and dev is cache: false, persistent: true.
pnpm test:e2e delegates to the frontend's playwright test, but no playwright.config.* and no
*.spec.ts files are committed anywhere in the repository, and IMPLEMENTATION.md still lists
"P15 Playwright E2E at 3 viewports" as outstanding. Expect the command to fail until that lands. The
three-viewport arrangement is the recorded intent (decision D4), not something the tree currently
contains.
Documentation site specifically
pnpm --filter @lucille/docs dev # docusaurus start --port 3002
pnpm --filter @lucille/docs build # production build; fails on any broken internal link
pnpm --filter @lucille/docs typecheck # tsc --noEmit
pnpm --filter @lucille/docs lint # eslint src docusaurus.config.ts sidebars.ts
pnpm --filter @lucille/docs test # node ./scripts/check-docs.mjs
pnpm --filter @lucille/docs serve # docusaurus serve --port 3002
The build is the real link checker: onBrokenLinks and onBrokenMarkdownLinks are both set to throw,
so a typo in a relative link fails the build rather than shipping a dead page. test runs the
zero-dependency check-docs.mjs, which asserts that every page carries id, title, sidebar_label and
description front-matter, that the front-matter id matches the filename, that every doc id referenced in
sidebars.ts exists, that no page is an orphan, and that no page falls below 300 prose words (fenced code,
inline code, HTML comments and table pipes are excluded from that count).
Known local differences from production
| Aspect | Local | Cluster |
|---|---|---|
| Redis | Single node, no persistence | HA with Sentinel, 3 + 3 replicas |
| Object storage | MinIO, path-style addressing | DO Spaces, virtual-hosted addressing, full-access key |
| SFTP auth | Password | SSH key from Vault (SYSCO_SFTP_KEY_B64) |
| SMTP | Mailpit, accepts anything | Authenticated relay over TLS |
| Sentry | Disabled (empty DSN) | Enabled from a Vault-supplied DSN |
| Tracing | OTEL_ENABLED=false | Exported to the cluster OTLP collector |
| TLS | None | Terminated at the Kong ingress with cert-manager |
Verification
This page was verified against the source tree rather than against the plan. The commands, ports,
credentials, environment-variable names and defaults above were read from docker-compose.yml,
scripts/native-services.sh, scripts/postgres-init/01-create-databases.sql, scripts/seed-zitadel.ts, the
root package.json, turbo.json, .env.example, apps/api/src/config/index.ts,
apps/api/src/auth/jwt.ts, apps/api/prisma/seed.ts, apps/api/prisma/seed-constants.ts,
apps/api/vitest.config.ts, apps/api/tests/global-setup.ts, apps/worker/vitest.config.ts, each app's
package.json, and IMPLEMENTATION.md. Anything that could not be confirmed there is flagged inline as
unverified, with the reason.
Where to go next
- Testing — the suites and how to run them.
- Troubleshooting — when something in this stack misbehaves.
- Deployment — how the same images reach the cluster.