Local setup
The whole stack runs locally under Podman, with the same container images you run in production. There is no separate “dev mode” arrangement to learn — production swaps the compose topology for Kubernetes (see ADR-07), but the images and configuration are the same.
Prerequisites
Section titled “Prerequisites”| Tool | Version | Needed for |
|---|---|---|
| Podman | 5+ | Everything. |
podman-compose | 1.5.0 | The compose front end. See the note below. |
| Rust | 1.83+ | Working on api/ outside the container |
| Bun | 1.1+ | Working on web/ or docs/ outside the container |
sqlx-cli | 0.8 | Creating migrations, preparing offline query data |
psql | 16+ | Poking at the database. Optional but you will want it. |
The container images build everything they need, so you can run the full stack with only Podman. The toolchains above are for iterating on a single service without a rebuild each time.
First run
Section titled “First run”-
Create your environment file.
Terminal window cp .env.example .envThree values have no default and compose will refuse to start without them:
POSTGRES_PASSWORD,SESSION_PEPPER, andS3_SECRET_KEY. That is on purpose — a stack that boots with a blank pepper is a stack that will eventually ship with a blank pepper. -
Generate the secrets.
Terminal window ./ops/gen-secrets.sh >> .envThe script emits 32 random bytes, base64-encoded, for each required secret. If you would rather do it by hand:
Terminal window echo "POSTGRES_PASSWORD=$(openssl rand -base64 32)" >> .envecho "SESSION_PEPPER=$(openssl rand -base64 32)" >> .envecho "S3_SECRET_KEY=$(openssl rand -base64 32)" >> .envTerminal window function New-Secret {$b = [byte[]]::new(32)[System.Security.Cryptography.RandomNumberGenerator]::Fill($b)[Convert]::ToBase64String($b)}"POSTGRES_PASSWORD=$(New-Secret)" | Add-Content .env"SESSION_PEPPER=$(New-Secret)" | Add-Content .env"S3_SECRET_KEY=$(New-Secret)" | Add-Content .env -
Bring the stack up.
Terminal window podman-compose up --buildIf you are on a Docker host instead,
docker compose up --buildreads the samedocker-compose.ymlunchanged.Order is enforced by healthchecks, so nothing races:
db (healthy) ──▶ migrate (runs once, exits 0) ──▶ api (healthy) ──▶ webcache (healthy) ─────────────────────────────────┘storage (healthy) ──▶ storage-init (creates the bucket, exits 0)migrateis a one-shot that applies the single baseline migration,0001_baseline.sql, inapi/migrations/and exits. It is aservice_completed_successfullydependency ofapiandworker, so nothing serves traffic against an unmigrated schema. Migrations never run from app boot.The baseline was squashed from the original 27-migration chain — no deployment held data — so the whole schema now arrives in one file. It is still organised into the same logical sections:
Section of 0001_baseline.sqlAdds Extensions and roles Extensions, app/catalogschemas,app.current_tenant_id(),app.apply_tenant_rls(), runtime rolesCatalog Global vendor catalog: styles, colors, variants, prices, inventory, sync runs Identity Tenants, users, roles, memberships, sessions, tokens, vendor accounts Shop Companies, customers, addresses, deals, line items, size runs Design Designs, versions, assets, imprints, approvals Jobs and audit Job queue, transactional outbox, partitioned audit log, external refs Size reference data Canonical sizes, the vendor alias map, catalog.resolve_size()RLS audit app.rls_exemptionsandapp.rls_audit()— the self-checking isolation guarantee -
Confirm it is up.
Terminal window curl -s localhost:8080/health/ready | jq# { "status": "ok", "checks": { "postgres": "ok", "valkey": "ok" } }open http://localhost:3000 # the appopen http://localhost:4321 # these docsopen http://localhost:9001/rustfs/console/ # RustFS console
Everything binds to 127.0.0.1, never 0.0.0.0. Publishing a development database to your local
network is how a dev Postgres ends up on Shodan.
| Service | URL | What it is |
|---|---|---|
web | http://localhost:3000 | SvelteKit on Bun — the app |
api | http://localhost:8080 | Rust API. Published for debugging only. |
docs | http://localhost:4321 | This site |
db | localhost:5432 | Postgres 17 |
cache | localhost:6379 | Valkey 8 |
storage | http://localhost:9000 | RustFS S3 endpoint |
| RustFS UI | http://localhost:9001/rustfs/console/ | RustFS console — log in with S3_* from .env |
POSTGRES_PORT and VALKEY_PORT in .env move the two that most often collide with something
already running.
Environment reference
Section titled “Environment reference”# ---------------------------------------------------------------- requiredPOSTGRES_DB=platynPOSTGRES_USER=platynPOSTGRES_PASSWORD=
# 32+ bytes, base64. Peppers the session token hash so a stolen DB dump# alone cannot be used to forge session lookups.SESSION_PEPPER=
# RustFS / S3S3_ACCESS_KEY=platynS3_SECRET_KEY=
# ---------------------------------------------------------------- optionalPOSTGRES_PORT=5432VALKEY_PORT=6379RUST_LOG=info,platyn=debug,sqlx::query=warn
# ---------------------------------------------------------------- vendors# Platform-level catalog credentials. The catalog is global (ADR-04), so these# belong to the platform, not to any tenant. Leave blank to run without live# vendor sync — the seed fixture provides a usable local catalog.SS_ACCOUNT_NUMBER=SS_API_KEY=SANMAR_CUSTOMER_NUMBER=SANMAR_USERNAME=SANMAR_PASSWORD=The API reads configuration through figment with a PLATYN__ prefix and
__ as the nesting separator, so PLATYN__DATABASE__URL sets database.url. Compose sets those
directly; the root .env only holds the handful of values compose itself interpolates.
Secrets as files in production
Section titled “Secrets as files in production”.env is a local-dev convenience. In production, secrets are not passed as environment values at
all — a Vault-agent sidecar renders them to files on a mounted path, and the app reads them
through a *_FILE indirection. For any PLATYN__… key, platyn-config also accepts a
PLATYN__…_FILE variable pointing at a mounted file, reads the file, and uses its contents as the
value:
# Instead of PLATYN__DATABASE__PASSWORD=…, point at the rendered file:PLATYN__DATABASE__PASSWORD_FILE=/run/secrets/db-passwordKubernetes projects secrets as files rather than environment variables, so the secret never lands
in the process environment or a compose file. The _FILE form and the direct form are
interchangeable; local dev uses the direct .env values, production uses the file-mounted ones.
Everyday commands
Section titled “Everyday commands”# tail one servicepodman-compose logs -f api
# rebuild just the API after a Rust changepodman-compose up -d --build api
# re-run migrations after adding onepodman-compose run --rm migrate
# a psql shellpodman-compose exec db psql -U platyn -d platyn
# seed the local catalog fixturepodman-compose exec api /usr/local/bin/platyn-cli seed --fixture catalog-slim
# confirm tenant isolation is intact — must print zero rowspodman-compose exec db psql -U platyn -d platyn -c 'SELECT * FROM app.rls_audit();'
# start over completely (destroys the volumes)podman-compose down -vWorking on one service natively
Section titled “Working on one service natively”The container stack is the source of truth, but an inner loop through a Docker build is miserable. Run the dependencies in Docker and the service you are editing on the host.
podman-compose up -d db cache storage storage-init migrate
cd apiexport PLATYN__DATABASE__URL='postgres://platyn:<password>@localhost:5432/platyn'export PLATYN__VALKEY__URL='redis://localhost:6379'export PLATYN__AUTH__SESSION_PEPPER='<pepper from .env>'export PLATYN__STORAGE__ENDPOINT='http://localhost:9000'cargo run --bin platyn-apicargo watch -x 'run --bin platyn-api' if you want it reloading.
podman-compose up -d api
cd webbun installPLATYN_API_URL=http://localhost:8080 bun run dev # :5173 with HMRcd docsbun installbun run dev # :4321, hot reloadbun run dev runs scripts/sync-openapi.ts first, which stages the OpenAPI document and the
Scalar bundle into public/.
Connecting as the right database role
Section titled “Connecting as the right database role”Migrations create two NOBYPASSRLS runtime roles. The compose POSTGRES_USER is the owner and,
for casual psql use, that is fine — but anything testing tenant isolation must connect as
platyn_app, because the owner is the one identity for which a mistake in the policy set would
not show up.
SET ROLE platyn_app;SELECT set_config('platyn.tenant_id', '00000000-0000-0000-0000-000000000001', false);SELECT count(*) FROM app.memberships; -- only that tenant's rows
RESET ROLE;FORCE ROW LEVEL SECURITY means even the owner is subject to policies, but the SET ROLE habit
is still worth having. See ADR-03.
Repository layout
Section titled “Repository layout”Directoryplatyn
- .env.example
- docker-compose.yml
Directoryapi/ Rust workspace
- Cargo.toml
Directorymigrations/
0001_baseline.sql- …
Directorycrates/ platyn-core, platyn-db, platyn-catalog, platyn-identity, …
- …
Directorybins/ platyn-api, platyn-worker, platyn-cli
- …
Directoryweb/ SvelteKit on Bun
- …
Directorydocs/ this site
- astro.config.mjs
- openapi.placeholder.json
- scripts/sync-openapi.ts
Directorysrc/content/docs/
- …
Directoryops/
- gen-secrets.sh
Directorypostgres/init/
- …
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause and fix |
|---|---|
set POSTGRES_PASSWORD in .env | Compose’s :? guard fired. You have no .env, or the value is blank. |
migrate exits non-zero | Read its logs before anything else — api and worker will not start until it succeeds. Usually a SQL error in the newest migration. |
| Every query returns zero rows | Working as intended: platyn.tenant_id is unset, so RLS admits nothing. Call the tenant transaction helper, or set_config in psql. |
api healthy, web cannot reach it | Inside the compose network the API is http://api:8080, not localhost:8080. Check PLATYN_API_URL. |
| Port already allocated | Change POSTGRES_PORT / VALKEY_PORT in .env, or stop whatever is holding the port. |
| Artwork upload fails with a signature error | storage-init did not run. podman-compose up storage-init to create and version the platyn-artwork bucket. |
| Docs build fails on the OpenAPI step | api/openapi.json exists but is malformed. Delete it to fall back to the committed placeholder, or fix the generator. |
| Everyone got logged out | SESSION_PEPPER changed. Existing token_key values no longer match. Expected. |