Skip to content

The Demo Instance

Austin Stanfield edited this page Aug 24, 2026 · 3 revisions

The demo instance

A fictional wedding anyone can poke at, on an instance that cannot be changed.

There is one running. No login — walk into /admin and change anything you like; a refresh puts it all back.

A second stack running the same image as production with one environment variable flipped. It holds a completely fictional wedding, it has no login, and nothing anyone does to it is ever saved. That combination is the point: you can hand the URL to somebody, tell them to go anywhere and change anything, and know that the next visitor sees the demo exactly as seeded.

docker compose -f docker/docker-compose.demo.yml up -d
# http://localhost:3001 — no password, and no /admin/login

That is the whole setup. It shares nothing with production but the image: its own database, its own volumes, its own port.

What demo mode changes

DEMO_MODE=true on the normal image:

Normal instance Demo instance
/admin Redirects to /admin/login Open. /admin/login redirects inwards
ADMIN button in the nav Only when logged in Always
A write to /api/… Runs Answered, not stored
Top of every page — A banner saying so

Writes are dropped in the middleware, not in the routes. Every POST / PATCH / PUT / DELETE under /api/ is intercepted before it reaches its handler and answered with a plausible success — the fields you sent, echoed back, plus a synthetic id. Client code carries on as if it worked, so the UI updates and the change is there on screen; the next GET returns the seeded data again, which is exactly the "change anything, refresh, it's all back" behaviour the demo wants.

Doing it in one place rather than in the 26 routes that write is deliberate: a feature added next month cannot forget to be immutable, and the guarantee doesn't depend on any particular handler being careful. It is also what makes having no login safe.

Two consequences worth knowing. The instance is shared, not per-visitor — two people looking at once each see the seed plus their own unsaved edits, never each other's. And a photo upload is refused like any other write, so the file never lands (the grid may show a placeholder until you navigate away).

The flag is hard to turn on by accident

src/lib/demo.ts refuses DEMO_MODE=true unless the database in DATABASE_URL is actually named demo, and logs once when it refuses. Every default fails towards "this is production":

  • Not set, or set to anything but true → normal instance.
  • Set, but pointed at the real database → refused, and the site keeps its login and its writes.
  • The banner and the always-on ADMIN button come from the same function, so an instance cannot be silently read-only either — if writes are being dropped, the banner is up.

The demo compose file still sets ADMIN_PASSWORD=demo. It is vestigial while the flag holds — there is no login page to type it into — and it is there so that if the flag is ever refused, the instance falls back to asking for a password rather than standing wide open.

Postgres is deliberately not published to the host in that compose file. The demo is meant to be reachable by anyone and its password is demo; a published port would let a visitor write to the database directly with psql, straight past a flag that only guards the application.

It seeds itself, on every start

The stack has a one-shot seed service that waits for the database, wipes it and fills it — the guest list, the RSVPs, the budget, the seating, the photos, the honeymoon — writing config and photos straight into the same volumes the web service mounts. No docker cp step.

Reseeding on every start is safe because the instance is immutable: there is no visitor's work to protect. It is also how the demo stays current — a fresh seed is how it gains data for features that shipped since it was last deployed, which is the whole reason the seeder is built and published on every push to main. Drop SEED_ALWAYS=true and the seeder reverts to filling only an empty database.

The seed image is ghcr.io/soccerbeats/weddingwebsite-seeder:latest — the seeder stage of docker/Dockerfile, published by the Demo Instance workflow. See Deployment.

To seed by hand instead, against any database:

DATABASE_URL=postgresql://demo:demo@host:5432/demo \
  CONFIG_DIR=/tmp/demo/config PHOTO_DIR=/tmp/demo/photos \
  npm run seed:demo -- --yes-wipe

It refuses to run without --yes-wipe and prints the database it is about to empty. Never point it at production.

What's in it

The content lives in src/lib/demoSeed.ts and is entirely invented: Maya and Theo, ninety guests with addresses and dietary notes, twenty-six RSVPs, a budget of twenty-eight lines, thirteen tables with a hundred-odd people seated, and a sixteen-day honeymoon in Portugal. The guest list is generated from a fixed seed, so re-seeding gives the same names and a screenshot stays true.

Two deliberate choices. Photographs are fetched from Lorem Picsum with a fixed seed per file, so the demo ships with real images rather than grey rectangles and the same run always produces the same pictures — --no-photos skips the download when re-seeding. And the honeymoon's places are real Portuguese landmarks with approximate coordinates, because a map of invented points looks like a bug.

Running one yourself

docker/docker-compose.demo.yml is the file, and it pins its project name to weddingdemo so the stack keeps that name (and its volumes) however you invoke it. Anything else you need to know is in the comments at the top of it.

To redeploy after a push, once the image has published:

docker compose -f docker/docker-compose.demo.yml pull
docker compose -f docker/docker-compose.demo.yml up -d

The pull gets the new app image and the new seeder; up -d recreates the web container and runs the seed again, so the demo comes back holding data for whatever just shipped.


← All pages

Clone this wiki locally