Files
member-console/docs/hosting.md
T
cgalo5758 f9c9b3225e Run the console on a runtime user and migrate on a second connection string
Fourth section of #23. db-dsn now names the console's runtime user, and
the new db-migrate-dsn (or db-migrate-dsn-file) names the user that runs
migrations. start opens both, migrates on the migration connection,
grants console_runtime to the runtime user there (WITH INHERIT TRUE, the
name quoted by the database), closes it, and serves on the runtime pool
alone. With db-migrate-dsn left out, start runs no migration and refuses
when the schema is behind its code, which is how a host runs migrations
as a separate step; migrate up, down and status use the migration string
and refuse without it, with no fallback to db-dsn.

Both pools are opened from one pgx.ParseConfig: the migration pool's
search_path is public and both pools' role is none, set in the startup
parameters after every key of either name is removed case-insensitively,
so neither a setting the database or role carries nor a key the string
repeats in another case moves a session off its login or its path.
PendingMigrations reads each stream's ledger by qualified name inside a
transaction pinned to search_path pg_catalog, pg_temp.

The test stack runs on two users: postgres-seed creates
member_console_runtime in new and existing volumes, bootstrap-stack.sh
writes MC_DB_DSN as that user and MC_DB_MIGRATE_DSN as member_console,
and the four scripts refuse a .env without the migration string, with
the command that regenerates it. Scripts and fixtures that need a
superuser on the application database (the reset, the screens'
timestamp pinning) use the migration string.

This worktree's stack was regenerated with that command and the console
booted as member_console_runtime: its sessions were the runtime user's
only, no migration-user session stayed open, and the runtime user could
not insert into core.pool_provisions.

Full suite on reset databases: 4277 pass, 15 skip, none failed. The
walkthroughs' names, results and skip messages match the previous
section's run. make screens lists three changed screens, all data:
regenerated ids on operator-person-detail and
operator-billing-invoice-detail (the demo snapshot was rebuilt) and a
domain's age on operator-domains (294d to 295d); the baseline was not
accepted. make lint passes.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (36):
  dbRuntime runs the migrations on the runtime pool
  dbRuntime skips the boot grant
  dbRuntime leaves the migration pool open
  dbRuntime skips the pending check when db-migrate-dsn is unset
  dbRuntime migrates on db-dsn when db-migrate-dsn is unset
  GrantRuntimeRole without WITH INHERIT TRUE
  GrantRuntimeRole with %s in place of %I
  PendingMigrations takes a missing ledger as current
  PendingMigrations resolves the ledger without the public. qualifier
  the migration pool opened without search_path=public
  the migration pool opened without role=none
  the runtime pool opened without role=none
  ConnectMigration keeps the search_path and role the string carries
  dbMigration falls back to db-dsn
  ValidateStart accepts a malformed db-migrate-dsn
  db-migrate-dsn-file is not a database secret pair
  db-migrate-dsn-file is not a database secret pair (the migrate commands read db-dsn-file only)
  the bootstrap guard removed
  reset-test-db.sh guard removed
  seed-demo.sh guard removed
  reset-app-db.sh guard after its first psql
  startup parameters match their keys case-sensitively
  the migration pool opened without role=none (keys and options in the string)
  the runtime pool opened without role=none (keys and options in the string)
  the runtime pool replaces the search_path the string carries
  the string's spaces are written as plus signs
  ValidateStart reads db-migrate-dsn as set whatever it holds
  PendingMigrations lists only the versions the ledger shows when it is missing
  migrationDSN keeps whitespace
  migrate up accepts a whitespace-only db-migrate-dsn
  runtimeDSN accepts a whitespace-only db-dsn
  openDB runs the migrating boot for an unknown mode
  bootstrap-stack.sh trusts the exported MC_DB_MIGRATE_DSN
  reset-test-db.sh trusts the exported MC_DB_MIGRATE_DSN
  reset-app-db.sh trusts the exported MC_DB_MIGRATE_DSN
  seed-demo.sh trusts the exported MC_DB_MIGRATE_DSN
2026-10-23 08:01:14 -04:00

3.8 KiB

title, audience, summary
title audience summary
Hosting member-console
admin
Build the container image and generate the secrets needed to run a member-console instance.

Hosting member-console

Operational steps for running your own member-console instance. For how the console fits alongside a public site and identity provider, see deployment-architecture.md; for identity-provider configuration, see identity-provider-setup.md.

Building the image

The container image is built with Docker Buildx for both ARM64 and AMD64. Tag it for your own registry:

docker buildx build \
  --platform linux/arm64,linux/amd64 \
  -t your-registry.example/your-org/member-console:latest \
  -t your-registry.example/your-org/member-console:$(date +%Y-%m-%d) \
  --push \
  .

The canonical image is published to git.coopcloud.tech/wiki-cafe/member-console. Substitute your own registry when self-hosting.

Secrets

Five configuration keys are secrets, each with a -file variant that reads the value from a file at startup (docs/environment-reference.md):

  • db-dsn — the PostgreSQL connection string of the runtime user, password included.
  • db-migrate-dsn — the PostgreSQL connection string of the migration user. Optional: with it unset, start runs no migrations, and only the step that runs member-console migrate up holds the migration user's string.
  • oidc-sp-client-secret — the OIDC client's secret, issued by the identity provider.
  • valkey-password — the session store's password. Required; the console refuses to start without one.
  • temporal-oauth-client-secret — only when Temporal is behind OAuth.

None of them is generated by or for the console: each is a credential for something external, obtained from that system. Sessions are stored server-side in Valkey and carry no signing secret of their own, and cross-origin request protection issues no token, so there is no signing key for it either; rotating any of the five means updating the deployment's configuration and restarting.

Session store limits

Every key member-console writes to the session store carries a TTL: a signed-in session lives for the deployment's configured lifetime, and a pre-sign-in session, the one /login or /register creates to hold the OIDC state, nonce, and PKCE verifier, lives fifteen minutes. A bare, unauthenticated request no longer writes anything to the store. A flood of /login or /register requests still writes one short-lived key per request, so the store's memory is still a resource a flood can spend.

Set maxmemory on the Valkey instance to a bound sized for the deployment's expected number of concurrent sessions, and set maxmemory-policy to volatile-lru. Under that policy, once the bound is reached, Valkey evicts the least recently used session first; that session can belong to a signed-in person, and losing it signs them out, so they sign in again. noeviction, Valkey's default, is worse: once maxmemory is reached it rejects new writes instead, and a write rejected during /login or /callback turns the flood into failed sign-ins for everyone hitting the store, not just whichever session would have been evicted.

Rate limit /login and /register at the reverse proxy, since the memory bound above caps damage but does not stop a flood from reaching the store in the first place. With Caddy and the caddy-ratelimit plugin's rate_limit directive, one zone covers both entry points:

@signin path /login* /register*
handle @signin {
    rate_limit {
        zone signin {
            key {remote_host}
            events 20
            window 1m
        }
    }
    reverse_proxy member-console:8080
}

Any reverse proxy that can key a limit by client address and scope it to a set of paths serves the same purpose.