Bindersnap Editor Demo
Monorepo with one unified frontend app and supporting services:
apps/app: SPA (static, on Cloudflare) with a pre-rendered landing page and authenticated workspace shell
services/api: auth/BFF API for the product SPA
services/hocuspocus: a collaboration websocket server, not run by any stack (the app has no real-time editing yet)
Quick Start
Prerequisites
bun install
cp tests/.env.example .env
bun run up
If you want to exercise the Stripe billing flow locally, fill in these values in
.env before starting the stack:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PRICE_ID=price_... for the subscription price you want to test
bun run test:integration now starts the Stripe CLI listener automatically for
hosted Checkout coverage when those Stripe test values are present. You only
need to set STRIPE_WEBHOOK_SECRET yourself when you want to exercise the
billing flow outside Playwright, for example with a manual bun run up session.
If a Stripe subscription exists but the local subscription row is missing, you
can rebuild it from Stripe with:
bun run reconcile:stripe-customer -- --username alice
bun run reconcile:stripe-customer -- --customer cus_123
Environment Variables
This is the complete environment variable reference used by repo code, scripts, compose wiring, or tests.
| Variable | Default | Used by | Purpose |
|---|
STACK_NAME | bindersnap | docker-compose.yml, Playwright global setup/teardown | Compose project name, container name prefix, and network name. Change it per worktree to run several stacks side by side. |
GITEA_PORT | 3000 | docker-compose.yml, tests/seed.ts, tests/helpers.ts | Gitea HTTP port, published on the host and used inside the compose network. |
API_PROXY_PORT | 8788 | docker-compose.yml (caddy), tests/Caddyfile.integration, tests | Port for the local Caddy proxy that fronts the API. |
APP_PORT | 5173 | server.ts, compose, Playwright config, seed script, integration script | Port for the app web server and base URL construction in local/test flows. |
API_PORT | 8787 | services/api/server.ts, compose | Port for the Bun API service. The browser does not use it — it calls whatever BUN_PUBLIC_API_BASE_URL was built with (the Caddy proxy port in the local stack). |
PORT | app/api dependent | server.ts, services/api/server.ts | Generic port override (fallback when app/api-specific vars are not set). |
NODE_ENV | unset | server.ts, API TLS policy | Enables production mode behavior (serve:* scripts set this). |
BUN_PUBLIC_API_BASE_URL | none | packages/api-client/mutator.ts | API origin the browser calls. Read when the bundle is built (or when the dev server starts) and inlined — a change needs a restart, not a reload. Unset means same-origin. |
BUN_PUBLIC_API_URL | none | docker-compose.yml, Playwright global setup | Legacy alias. Set by compose and the test runtime; no app code reads it. |
VITE_API_URL |
Local Stack
Use the dockerized stack when you want seeded Gitea + API + app together:
bun run up
Sign in at http://localhost:${APP_PORT:-5173} as alice, bob, carol, or
dan — every seeded account uses the password dev. The stack comes up with
documents in every review state: draft, in review, changes requested, ready to
publish, and published.
This stack is also the dev server: its app container bind-mounts the repo and
runs bun --hot server.ts, so edits under apps/ and packages/ hot-reload in
the browser with nothing to restart. A change to dependencies is the exception —
the container builds its own node_modules at image build, so adding a package
needs a full bun run down && bun run up.
Do not run a second SPA on a second port against this stack. The API sends CORS
headers for exactly one origin, http://localhost:${APP_PORT}, so
bun run dev:app elsewhere is blocked on every request; on its own it serves the
landing page and other UI that calls no API.
What gets seeded is described declaratively in
tests/seed-data/dev.yaml. Edit that file to add an
account, a document, or a review scenario; bun run test:seed validates it
without starting anything.
Running more than one stack at once
Every published port and every container name in docker-compose.yml comes from
an environment variable, so a second worktree can run its own stack alongside
the first. Give each worktree its own .env with a distinct STACK_NAME and a
distinct port block:
STACK_NAME=bindersnap-wt2
APP_PORT=5273
API_PORT=8887
API_PROXY_PORT=8888
GITEA_PORT=3100
STACK_NAME sets the Compose project name, the container name prefix, the
network name, and the volume prefix, so the two stacks never touch each other's
containers or data.
See tests/README.md for full workflow details.
Deployment
Two surfaces, both triggered by a push to main:
The backend is a single EC2 instance, not a serverless stack. Its entire
configuration lives in deploy/ as a
pyinfra script. GitHub Actions pushes the deploy: it
assumes an OIDC role, pushes a ~60-second ephemeral key with EC2 Instance
Connect, tunnels SSH through aws ssm start-session, and runs
pyinfra deploy/inventory.py deploy/deploy.py. The host has no inbound port
22 and there are no long-lived keys.
deploy.py is idempotent — a fresh host converges to the current stack with no
pre-configuration, and a re-run changes nothing unless config or secrets
changed. Terraform under infra/ provisions AWS resources only; it
does not configure the host.
Run it yourself (dry run applies nothing):
python3 -m venv deploy/.venv
deploy/.venv/bin/pip install -r deploy/requirements.txt
source deploy/.venv/bin/activate
deploy/bin/ssm-connect.sh --dry
Production Secrets
Production no longer relies on a repo-side .env.prod. The pyinfra deploy
(deploy/deploy.py) renders /opt/bindersnap/.env.prod on every run by
reading /bindersnap/prod/* from SSM Parameter Store on the control plane.
Use .env.prod.example
as the schema for the generated file only. The committed example keeps
placeholders for the SSM-backed values and documents the non-secret runtime
overrides that can still be passed at deploy time.
The production API expects two tokens for the bindersnap-service account in
that generated env file:
GITEA_SERVICE_TOKEN — read scopes only. Every privileged read uses it, on
nearly every request.
GITEA_ADMIN_TOKEN — write:admin alone, for signup, password changes,
account deletion and token revocation.
The service account is a Gitea site admin, so scopes are what keep the hot
read paths from holding root-equivalent power. When either SSM value is the
bootstrap placeholder, the pyinfra run starts Gitea with the first-boot admin
credentials from SSM, runs scripts/bootstrap-gitea-service-account.ts in a
throwaway Bun container to mint both, writes them back to
/bindersnap/prod/gitea_service_token and /bindersnap/prod/gitea_admin_token,
refreshes /opt/bindersnap/.env.prod, and only then starts the API.
A host that predates the split has one token with both scope sets. Applying the
secrets Terraform creates gitea_admin_token as a placeholder; the next deploy
then re-mints the service token read-only and mints the admin token. Until that
deploy, the API uses the one token for both.
To support that flow, set these secrets in infra/secrets/terraform.tfvars
before infra/apply-all.sh apply:
gitea_admin_user = "gitea-admin"
gitea_admin_pass = "REPLACE_WITH_OPENSSL_OUTPUT"
infra/apply-all.sh apply now also dispatches the same bootstrap over AWS SSM
to the current prod instance after the secrets module is applied, so existing
instances pick up the service token without needing an EC2 rebuild.
Manual create or rotation is still available with:
bun scripts/bootstrap-gitea-service-account.ts
The bootstrap script uses GITEA_ADMIN_USER and GITEA_ADMIN_PASS only long
enough to ensure the bindersnap-service account exists, grant admin, mint the
read-only and write:admin PATs, and write them to
/bindersnap/prod/gitea_service_token and /bindersnap/prod/gitea_admin_token.
After both tokens are real, the env render stops writing those admin
credentials into /opt/bindersnap/.env.prod, so the steady-state compose stack
does not keep them in its runtime env file.
Production Backups
docker-compose.prod.yml includes a litestream sidecar that continuously
replicates the Gitea SQLite database and the API session database to S3. Before
starting the production stack, ensure the SSM-backed generated env file at
/opt/bindersnap/.env.prod includes LITESTREAM_S3_BUCKET, and leave
AWS_REGION aligned with the bucket region.
To restore from S3 during an incident:
docker compose -f docker-compose.prod.yml --env-file /opt/bindersnap/.env.prod down
export LITESTREAM_S3_BUCKET=bindersnap-litestream-123456789012
./scripts/restore.sh gitea
docker compose -f docker-compose.prod.yml --env-file /opt/bindersnap/.env.prod up -d
Use ./scripts/restore.sh api to restore the API session store instead. The
script assumes the production Docker volumes are mounted at /data/..., so run
it from the production app host or an equivalent recovery environment.
Production API Image
The production API service runs from a published GHCR image instead of a source
bind mount. deploy-pyinfra.yml calls the reusable
.github/workflows/build-api.yml for the exact commit being deployed, which
pushes:
ghcr.io/davidgraymi/bindersnap-api:${GITHUB_SHA}
ghcr.io/davidgraymi/bindersnap-api:latest
The host pulls the image selected by API_TAG in /opt/bindersnap/.env.prod.
The deploy pins that to the deployed commit's SHA — the immutable tag, not
mutable :latest. Normal rollback is a git revert on main, or running
deploy-pyinfra.yml via Run workflow with the api_tag input set to a
last-good SHA. For a pin that survives subsequent pushes, set the SSM api_tag
override (see docs/ops/break-glass.md).
The commands below are the manual fallback for when GitHub Actions is
unavailable — the normal path is a deploy. To deploy the currently selected API
tag by hand on the host:
cd /opt/bindersnap
docker compose -f docker-compose.prod.yml --env-file /opt/bindersnap/.env.prod pull api
docker compose -f docker-compose.prod.yml --env-file /opt/bindersnap/.env.prod up -d api
To roll back by hand, set API_TAG to a previous commit SHA in
/opt/bindersnap/.env.prod, then run the same pull and up -d api commands
again. Note that the next pyinfra deploy re-renders .env.prod and undoes a
hand-edited pin — use the SSM override for anything that must persist.
The end-to-end production deploy workflow, required GitHub variables, and the
GitHub Actions rollback path are documented in
docs/ops/deploy.md.