Reading map: Demo showcase · Testing auth flows · Deployment · Troubleshooting install

Local Development with Docker

This recipe is for working on Sigra (or evaluating it deeply) on your own machine: running the test suite, booting the seeded demo app, and reaching it at a stable URL — all backed by Docker so it never fights your system Postgres or another project's ports. If you are running many Elixir apps at once (each with its own admin UI), this is the workflow that keeps them out of each other's way. Budget: under 15 minutes.

If you just want to install Sigra into your own app, you want Installation instead — this page is about the Sigra repo's own dev/demo environment.

Gameplan (TL;DR)

  1. Tests: scripts/db/up.shdirenv allow (once) → mix test. A throwaway Postgres on a random port; nothing to configure.
  2. Demo app: scripts/uat/up.shone command. It builds + boots the demo behind the shared Traefik proxy with live reload, waits until the app actually responds, auto-opens /demo/credentials, and prints grouped auth/admin/ops routes. No second terminal.
  3. Read the printout. Both scripts print the exact URLs, routes, and commands to copy-paste.
  4. Iterate. Save a template or lib/ source and it hot-reloads in the running container (bind-mount + inotify) — no image rebuild, and style edits never re-download deps. Prefer the raw host-run path? Use --dev.
  5. Tear down: scripts/db/down.sh and scripts/uat/down.sh.

What this gives you / what it doesn't

  • ✅ Postgres in Docker on a dynamic port — never reserves 5432, never collides with Homebrew Postgres or a sibling project.
  • ✅ A stable, collision-free URL per checkout/branch, with http://sigra.localhost as a friendly alias for your primary checkout.
  • ✅ A Docker image whose layers cache correctly — editing a template doesn't re-fetch or recompile dependencies.
  • ❌ Not a production setup. For the prod release image and deploy config see Deployment.

Prerequisites

  • Docker Desktop running.
  • Elixir 1.19+ / OTP 27+ (host-run mode and mix test run on the host).
  • A modern browser. Chrome/Edge resolve *.localhost automatically; for Firefox/Safari/curl use the printed raw 127.0.0.1:<port> URL.
  • Optional but recommended: direnv for zero-step test-DB discovery (a source fallback is always printed).
  • For the install golden tests only: the pinned phx_new archive — mix archive.install --force hex phx_new 1.8.8.

Postgres for mix test

mix test needs a Postgres with postgres/postgres and a sigra_test database. Boot a disposable one in Docker:

scripts/db/up.sh          # ephemeral Postgres on a dynamic port; writes tmp/db.env
direnv allow              # one-time; auto-loads tmp/db.env on every cd into the repo
mix test

No direnv? Use the printed fallback instead:

source tmp/db.env && mix test

How it works: the container publishes Postgres on a random host port (so it can't clash with anything), scripts/db/up.sh discovers that port and writes it to tmp/db.env in both naming conventions — SIGRA_TEST_PG_* (read by the library suite) and PG* (read by the demo app and the install fixtures). When tmp/db.env isn't loaded (CI, or a plain Postgres on 5432), everything falls back to localhost:5432, so nothing breaks. The container is ephemeral (no data volume) and raised to max_connections=200 for the install suite's many generated apps. Tear it down with scripts/db/down.sh.

This test database is separate from the demo database below. Keep them apart: the demo DB persists seeded personas; the test DB is disposable.

PR-fast vs nightly CI split

A clean local mix test plus the PR-fast gate (the 5 required lanes + install_golden_contract + library_tests_dep_off) is the fast feedback loop; exhaustive broad coverage (install matrix ×4, upgrade smoke, generated-admin Playwright ~60 min) now runs on a nightly schedule/main push, not on every PR.

Reproducing the CI dep-off lane locally

The CI dep-off lane verifies that Sigra compiles and the core guard tests pass when the optional :threadline dep is absent. Reproduce it locally with a single command:

MIX_ENV=test mix sigra.dep_off

This alias (in mix.exs) runs four steps in order:

  1. deps.unlock threadline — removes the lockfile entry for :threadline.
  2. deps.clean threadline --build — clears cached BEAM files for :threadline.
  3. compile --warnings-as-errors --no-deps-check — proves the library compiles cleanly without the dep (the D-09 compile-proof gate).
  4. test --only threadline_guard --no-deps-check — runs only the modules tagged @moduletag :threadline_guard, which cover all dep-off guard paths (D-10/D-11 coverage selection).

This requires the same live test Postgres as the full suite (boot one via scripts/db/up.sh if needed). After running mix sigra.dep_off, restore the dep with mix deps.get.

Larger-runner measurement gate (CACHE-03)

GitHub's standard ubuntu-latest runner (2 vCPU, 7 GB RAM) is free and unlimited on public repos. Larger runners (4/8/16/32 vCPU) are billed per-minute even on public repos and do not consume included minutes — they bypass the free tier entirely. At the time of writing (June 2026, GitHub Docs), a 4-core larger runner costs ~$0.016/min, while two standard runners share zero cost.

The default posture is: stay on ubuntu-latest. Do not adopt a larger runner.

Decision rule (D-22)

Adopt a larger runner for a specific job ONLY if ALL of the following are true:

CriterionTest
The job is on the critical pathIts duration directly extends run-level wall-clock
The job is genuinely un-shardableCannot be split across multiple standard runners at lower cost
Δwall-clock is material (≥30% on that job)A/B measured, not estimated
The run-level wall-clock actually dropsThe job is not masked by a longer pole (e.g. Playwright ~22m)
The recurring $/min is acceptedCost reviewed and approved

If any criterion is false: reject. 2 free standard shards (4 effective cores, $0/min) strictly dominate 1 paid 4-core runner for parallelizable work.

Measurement procedure

  1. Take the baseline. Use the per-job wall-clock durations from 193-BASELINE.md as your before-state. For the job under test, note: runner label, wall-clock, and cache-hit.
  2. A/B test on a larger runner label (e.g., ubuntu-latest-4 or ubuntu-4-core). Run the job at least 3 times to average out scheduler jitter.
  3. Measure: wall-clock, billed-minutes, and cache-hit for each run.
  4. Apply the decision rule from the table above. If a criterion fails, stop: reject and document the reason.
  5. Record a before/after table (template below) and include it in the phase SUMMARY.md.

Before/after table template

JobRunner labelWall-clockBilled-minutesCache-hitVerdict
(job name)ubuntu-latest(baseline)0 (free)(yes/no)— baseline
(job name)(larger label)(A/B result)(billed-mins)(yes/no)accept/reject

Fill this table with real measurements before merging any larger-runner adoption. A missing or estimated table is grounds to revert the change in the next phase.

Current status

As of Phase 195, no larger runners are adopted. Every job in ci.yml uses runs-on: ubuntu-latest. The partition win (TEST-01: 2 standard shards halving library_tests walltime at $0/min) and the dep-off subset win (TEST-02: library_tests_dep_off targeting only the 65-test guard subset instead of the full suite) are the load-bearing Phase 195 gains. Larger runners were evaluated against the decision rule and rejected: the wall-clock pole is example_playwright_smoke (~22m, Phase 197 target), not any job that a larger runner would materially shorten.

Running the demo app

One command does everything:

scripts/uat/up.sh

That single command:

  1. starts a project-scoped Postgres and creates/migrates/seeds the example_dev database,
  2. builds + boots the demo as a container behind the shared Traefik proxy (bringing the proxy up automatically if it isn't running), with the repo bind-mounted for live reload,
  3. waits until the app actually responds (a readiness probe — the URL is never printed as live while it's still STARTING),
  4. auto-opens http://…localhost/demo/credentials, and
  5. prints grouped auth / admin / ops routes to copy-paste (sign in as admin@demo.vaultr.test for the admin routes).

Save a test/example template or a lib/ source file and it hot-reloads in the running container — no image rebuild. There's no second terminal: the server is already up by the time the script returns.

Flags

FlagWhat it does
(none) / --proxyThe default: Dockerized demo behind shared Traefik, live reload, health-gated, auto-open. --proxy is just an explicit alias of the default.
--dev / --hostHost-run Phoenix instead of the container — fastest live reload, no Docker app build. Starts + health-gates the server for you, in the background.
--attach / --iexHost-run in the foreground, bound to an IEx shell (Ctrl-C twice to stop). Implies the host-run path.
--no-watchProxy mode without the bind-mount live-reload override (apply source changes with --refresh-code).
--no-openDon't auto-open the browser when the app is ready.
--resetDrop and recreate the demo database first.
--no-seedSkip seeding the demo personas.
--private-traefikHost-run fallback behind a project-private Traefik on :18080 (no shared :80).

Running several Sigra-family libs at once

The shared proxy is what keeps multiple Elixir apps (each with its own admin UI) out of each other's way:

  • One global Traefik (scripts/dev-proxy/up.sh, auto-started) owns 127.0.0.1:80 and routes every project's .localhost hostname over the shared proxy Docker network — so Sigra and sibling libraries coexist without anyone reserving port 80 or 4000.
  • Each checkout/branch gets a unique host (sigra-<branch>-<hash>.localhost), derived so two branches or worktrees never collide. (Two Traefik routers with the same host would silently round-robin between them — the unique-by-construction host avoids that.)
  • http://sigra.localhost is claim-based. The first stack to claim it — on any branch, not just the default — gets the friendly alias; everyone else keeps their unique per-checkout host. So you still get the clean URL on a feature branch.
  • Per-host cookie jars. A session on sigra.localhost is not shared with sigra-feature-x.localhost. That's a feature for auth testing, not a bug.
  • Firefox / Safari / curl don't resolve *.localhost. Use the printed RAW FALLBACK http://127.0.0.1:<port> for those.

How the Docker build caches your changes

The --proxy image (scripts/uat/Dockerfile.example) is layered so a change only rebuilds what it must — you will never re-download or recompile dependencies for a template tweak:

You changed…What rebuilds
An example template / .heex / controllerOnly the final example-compile layer (fast incremental). Deps + sigra stay cached.
Sigra library source (lib/, priv/)The dependency layer recompiles (sigra + its deps); the example recompiles.
mix.exs / mix.lockDependencies re-resolve and recompile.

After editing Sigra source while a --proxy stack is running, apply it with:

scripts/uat/up.sh --refresh-code

That does a cache-aware docker compose build + restart — not a from-scratch rebuild.

Iterating on code

  • Day-to-day (default): scripts/uat/up.sh. Save a test/example template or lib/ source → it hot-reloads in the container (bind-mount + inotify). No image rebuild in the loop, and no second terminal.
  • Fastest reload: scripts/uat/up.sh --dev runs Phoenix on the host directly — no container layer at all. Tail tmp/uat-phoenix.log for output, or --attach to drop into IEx.
  • No bind-mount: --no-watch runs the container without the live-reload mount; pick up source changes with scripts/uat/up.sh --refresh-code (cache-aware rebuild + restart).

Teardown

scripts/db/down.sh        # stop the test Postgres (--purge to also drop volumes)
scripts/uat/down.sh       # stop the demo stack (--purge to drop the seeded demo DB)

Troubleshooting

  • FATAL: sorry, too many clients already — a shared/system Postgres is saturated. The Dockerized test DB (scripts/db/up.sh) is isolated and raised to 200 connections; make sure tmp/db.env is loaded so tests use it, not a system 5432.
  • mix test connects to the wrong DB — confirm tmp/db.env is loaded (direnv allow, or source tmp/db.env). Unloaded, it defaults to localhost:5432 by design.
  • http://sigra.localhost doesn't resolve — you're likely in Firefox/Safari/curl; use the printed RAW FALLBACK 127.0.0.1:<port>.
  • Port 80 already owned — a sibling project's proxy already holds it; that's fine, Sigra routes through it. To run your own, set SIGRA_DEV_PROXY_HTTP_PORT.
  • Demo URL says STARTING — not yet responding — the readiness probe hasn't seen a 200 yet. Give it a few seconds; if it persists, check the logs. Host-run (--dev) logs are in tmp/uat-phoenix.log; container logs are docker compose … logs -f web (the exact command is printed under COMMANDS).
  • Hot reload feels slow on macOS — Docker bind-mount file-watching has noticeable latency on macOS. For the tightest save→reload loop, use scripts/uat/up.sh --dev (host-run, no bind mount).
  • Browser looks stale after a source change with --no-watch — you opted out of the live-reload mount; run scripts/uat/up.sh --refresh-code.
  • different value ... for ExampleWeb.Endpoint — the image was built with different bind/port than runtime; rebuild with --refresh-code.

Where to go next

Demo showcase for the guided evaluator walkthrough · Testing auth flows for writing tests · Deployment for the production release image.