Skip to content

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.

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 eight migrations 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.

    MigrationAdds
    0001_extensions_and_roles.sqlExtensions, app/catalog schemas, app.current_tenant_id(), app.apply_tenant_rls(), runtime roles
    0002_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_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 # 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.

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:9000MinIO S3 endpoint
MinIO UIhttp://localhost:9001MinIO 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=
# MinIO / 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.

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_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/
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.