Skip to content

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.

ToolVersionNeeded for
Podman5+Everything.
podman-compose1.5.0The compose front end. See the note below.
Rust1.83+Working on api/ outside the container
Bun1.1+Working on web/ or docs/ outside the container
sqlx-cli0.8Creating migrations, preparing offline query data
psql16+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.

  1. Create your environment file.

    Terminal window
    cp .env.example .env

    Three values have no default and compose will refuse to start without them: POSTGRES_PASSWORD, SESSION_PEPPER, and S3_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.

  2. Generate the secrets.

    Terminal window
    ./ops/gen-secrets.sh >> .env

    The 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)" >> .env
    echo "SESSION_PEPPER=$(openssl rand -base64 32)" >> .env
    echo "S3_SECRET_KEY=$(openssl rand -base64 32)" >> .env
  3. Bring the stack up.

    Terminal window
    podman-compose up --build

    If you are on a Docker host instead, docker compose up --build reads the same docker-compose.yml unchanged.

    Order is enforced by healthchecks, so nothing races:

    db (healthy) ──▶ migrate (runs once, exits 0) ──▶ api (healthy) ──▶ web
    cache (healthy) ─────────────────────────────────┘
    storage (healthy) ──▶ storage-init (creates the bucket, exits 0)

    migrate is a one-shot that applies the single baseline migration, 0001_baseline.sql, in api/migrations/ and exits. It is a service_completed_successfully dependency of api and worker, 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 rolesExtensions, app/catalog schemas, app.current_tenant_id(), app.apply_tenant_rls(), runtime roles
    CatalogGlobal vendor catalog: styles, colors, variants, prices, inventory, sync runs
    IdentityTenants, users, roles, memberships, sessions, tokens, vendor accounts
    ShopCompanies, customers, addresses, deals, line items, size runs
    DesignDesigns, versions, assets, imprints, approvals
    Jobs and auditJob queue, transactional outbox, partitioned audit log, external refs
    Size reference dataCanonical sizes, the vendor alias map, catalog.resolve_size()
    RLS auditapp.rls_exemptions and app.rls_audit() — the self-checking isolation guarantee
  4. 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 app
    open http://localhost:4321 # these docs
    open 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.

ServiceURLWhat it is
webhttp://localhost:3000SvelteKit on Bun — the app
apihttp://localhost:8080Rust API. Published for debugging only.
docshttp://localhost:4321This site
dblocalhost:5432Postgres 17
cachelocalhost:6379Valkey 8
storagehttp://localhost:9000RustFS S3 endpoint
RustFS UIhttp://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.

.env.example
# ---------------------------------------------------------------- required
POSTGRES_DB=platyn
POSTGRES_USER=platyn
POSTGRES_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 / S3
S3_ACCESS_KEY=platyn
S3_SECRET_KEY=
# ---------------------------------------------------------------- optional
POSTGRES_PORT=5432
VALKEY_PORT=6379
RUST_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.

.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-password

Kubernetes 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.

Terminal window
# tail one service
podman-compose logs -f api
# rebuild just the API after a Rust change
podman-compose up -d --build api
# re-run migrations after adding one
podman-compose run --rm migrate
# a psql shell
podman-compose exec db psql -U platyn -d platyn
# seed the local catalog fixture
podman-compose exec api /usr/local/bin/platyn-cli seed --fixture catalog-slim
# confirm tenant isolation is intact — must print zero rows
podman-compose exec db psql -U platyn -d platyn -c 'SELECT * FROM app.rls_audit();'
# start over completely (destroys the volumes)
podman-compose down -v

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.

Terminal window
podman-compose up -d db cache storage storage-init migrate
cd api
export 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-api

cargo watch -x 'run --bin platyn-api' if you want it reloading.

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.

  • 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/
        • …
SymptomCause and fix
set POSTGRES_PASSWORD in .envCompose’s :? guard fired. You have no .env, or the value is blank.
migrate exits non-zeroRead 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 rowsWorking 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 itInside the compose network the API is http://api:8080, not localhost:8080. Check PLATYN_API_URL.
Port already allocatedChange POSTGRES_PORT / VALKEY_PORT in .env, or stop whatever is holding the port.
Artwork upload fails with a signature errorstorage-init did not run. podman-compose up storage-init to create and version the platyn-artwork bucket.
Docs build fails on the OpenAPI stepapi/openapi.json exists but is malformed. Delete it to fall back to the committed placeholder, or fix the generator.
Everyone got logged outSESSION_PEPPER changed. Existing token_key values no longer match. Expected.