Local setup
The whole stack runs under Podman with the same compose topology used in production. There is no separate “dev mode” arrangement to learn.
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 eight migrations inapi/migrations/and exits. It is aservice_completed_successfullydependency ofapiandworker, so nothing serves traffic against an unmigrated schema. Migrations never run from app boot.Migration Adds 0001_extensions_and_roles.sqlExtensions, app/catalogschemas,app.current_tenant_id(),app.apply_tenant_rls(), runtime roles0002_catalog.sqlGlobal vendor catalog: styles, colors, variants, prices, inventory, sync runs 0003_identity.sqlTenants, users, roles, memberships, sessions, tokens, vendor accounts 0004_shop.sqlCompanies, customers, addresses, deals, line items, size runs 0005_design.sqlDesigns, versions, assets, imprints, approvals 0006_jobs_and_audit.sqlJob queue, transactional outbox, partitioned audit log, external refs 0007_size_reference_data.sqlCanonical sizes, the vendor alias map, catalog.resolve_size()0008_rls_audit.sqlapp.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 # MinIO 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 | MinIO S3 endpoint |
| MinIO UI | http://localhost:9001 | MinIO 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=
# MinIO / 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.
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_extensions_and_roles.sql,0002_catalog.sql,0003_identity.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. |