tanshu 82e5b7d0f6 Fix the Quadlet healthcheck so the deploy gate can pass
podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
instruction, so the first deploy failed at "Wait for the container healthcheck
to pass" with "container ... has no defined healthcheck" even though the app was
up and healthy.

- Declare the same check on the greatbear.container unit (HealthCmd plus
  interval, timeout, retries, start period), the convention hops and menu-admin
  already use.
- Flush handlers right after starting, before the gate: the unit template's
  restart handler otherwise runs at the end of the play, so a run that changes
  only the unit would test the stale container.

Mirrors hops 7366596, so the two deployments keep the same shape.
2026-10-16 10:54:12 +05:30

The Great Bear — Microbrewery Website

Full-stack site for The Great Bear microbrewery (SCO 32, Sector 26, Chandigarh), built from the Stitch "Industrial Hospitality" design.

Layer Tech
Frontend TanStack Start (React 19, Vite 8, Tailwind CSS 4, SSR)
Backend PocketBase v0.40 — CMS content, form intake, admin UI
Runtime Single image running both services, as a rootless Podman Quadlet unit
Proxy Caddy (automatic HTTPS), also a rootless Podman Quadlet unit
Deploys deploy.sh [tag] (Podman build + push) → Ansible playbook

Pages

  • / — hero, stats, featured brews, experiences, testimonials, quick table booking
  • /our-brews — flagship banner, core lineup, seasonal releases, tasting-flight builder
  • /menu — filterable gastropub menu with beer pairings
  • /visit — reservation form, contact info, map

All structured content (brews, menu, testimonials, stats, contact info) is served from PocketBase and editable in the admin UI. If PocketBase is unreachable, the site falls back to bundled seed content so it never renders empty.

Repository layout

app/                 TanStack Start application
  src/routes/        Pages (/, /our-brews, /menu, /visit)
  src/components/    Shared UI + client forms
  src/lib/           Server functions & PocketBase client
  src/data/content.json   Single source of truth for seed + fallback content
  public/images/     Design assets (downloaded from the Stitch export)
pb/migrations/       PocketBase schema (snapshot migration, auto-applied)
scripts/
  seed.mjs           Idempotent content seeder (create-if-missing by uid)
  create-collections.sh   Dev helper used to build the schema migration
ansible/             Deployment playbook (roles: register, greatbear, caddy)
Makefile             Podman build targets (build-production / build-check)
Dockerfile           Multi-stage image build (deps → builder → pb-downloader → runner)
docker-entrypoint.sh Container supervisor: PocketBase + site server
deploy.sh            Build & push image, then run the Ansible deploy
.containerignore     Build-context excludes (Podman/Buildah; .dockerignore fallback)

Local development

Frontend only (renders from bundled seed content; forms return errors):

cd app
npm install
npm run dev          # http://localhost:3000

Full stack with PocketBase (recommended — from the repo root):

# Terminal 1 — PocketBase on :8090 (data in ./pb_data, schema auto-applied
# from pb/migrations on first boot)
./.tmp-pb/pocketbase serve --http=127.0.0.1:8090 --dir=./pb_data --migrationsDir=./pb/migrations

# Terminal 2 — one-time setup: create a local admin and seed the content
./.tmp-pb/pocketbase superuser upsert dev@greatbear.in devpass12345 --dir=./pb_data --migrationsDir=./pb/migrations
PB_URL=http://127.0.0.1:8090 PB_SUPERUSER_EMAIL=dev@greatbear.in PB_SUPERUSER_PASSWORD=devpass12345 \
  node scripts/seed.mjs

# Terminal 2 — the site (talks to PocketBase on 127.0.0.1:8090 automatically)
cd app && npm run dev
  • PocketBase admin UI: http://127.0.0.1:8090/_/ (dev@greatbear.in) — edits show up on the site on the next page load
  • Dev data lives in ./pb_data (git-ignored); delete it for a factory reset
  • The PB binary in .tmp-pb/ was fetched during setup — if it's missing, grab a release from https://github.com/pocketbase/pocketbase/releases (or run PocketBase however you like; only the port matters)

npm run build produces .output/server/index.mjs, started with npm start. To run the production image locally instead:

podman build -t localhost/greatbear:dev .
podman run --rm -p 3000:3000 -p 8090:8090 \
  -e PB_SUPERUSER_EMAIL=dev@greatbear.in -e PB_SUPERUSER_PASSWORD=devpass12345 \
  -v greatbear_dev_data:/app/pb_data localhost/greatbear:dev

Deploying

The image is built with Podman/Buildah (not Docker) from the committed tree (git archive HEAD) and tagged with its commit id, so what runs on the host is exactly what git knows about. deploy.sh pushes the commit first, refuses a dirty tracked tree and requires HEAD == origin/main. Log in to the registry once per machine: podman login registry.tanshu.com.

./deploy.sh              # push, build <commit>, deploy it to emily
./deploy.sh --no-push    # skip the push (you already pushed)
./deploy.sh --force      # skip the guards

Or run the pieces yourself:

make build-production   # podman build HEAD, push :<commit> + :latest
make build-check        # compile check from HEAD, no push

cd ansible && ansible-playbook -u tanshu playbook.yml -e "image_tag=$(git rev-parse --short HEAD)"

The image is built for linux/arm64 by default because emily is aarch64. Podman has no built-in QEMU, so cross-building from an x86_64 controller needs binfmt there (qemu-user-binfmt, already installed on this machine); override with PLATFORM=linux/amd64.

Ansible deployment

Requirements on the controller: ansible (no collections — the play uses built-in modules plus the podman / systemctl --user CLI). The target host must already run the fleet's shared Caddy rootless Quadlet setup: the caddy/playbook.yml from the ansible-playbooks repo owns the Caddy unit, the rootless prerequisites (Podman, loginctl enable-linger, the low-port sysctl) and the Caddyfile this play appends to.

The play targets the inventory host emily (present in /etc/ansible/hosts), connects as the unprivileged container user and does not become: the Quadlet units belong to that user, and the SSH connection's PAM session is what makes systemctl --user work.

cd ansible
ansible-playbook -u tanshu playbook.yml -e "image_tag=<commit>"

All deployment variables live in ansible/vars/default.yml (registry, image tag, domains, PocketBase admin credentials). The play:

  1. Writes ~/.config/containers/systemd/greatbear.network (NetworkName=greatbear_net) and a drop-in that attaches the shared Caddy unit to it, so Caddy can resolve greatbear by container name.

  2. Logs in to the private registry, pulls registry.tanshu.com/tanshu/greatbear:{image_tag} (rollback is the same play with an older commit), writes ~/podman-data/greatbear/.env, installs ~/.config/containers/systemd/greatbear.container, starts it as a systemd user service and waits for the image's healthcheck (PocketBase /api/health + the site).

  3. Appends the site blocks to the shared Caddyfile (~/podman-data/caddy/conf/Caddyfile) and reloads Caddy:

    • www.greatbear.in (+ apex greatbear.in) — frontend on port 3000, PocketBase API/admin proxied under /api/* and /_/*
    • admin.greatbear.in — redirects to the PocketBase admin UI

Nothing is published on the host; Caddy reaches the container over greatbear_net. The container runs as uid 0, so the unit maps it onto the podman user with UserNS=keep-id:uid=0,gid=0: ~/podman-data/greatbear stays owned by tanshu, and PocketBase's files need no podman unshare to inspect or back up.

DNS records for all three hosts must point at the server; Caddy obtains TLS certificates automatically on first request.

PocketBase admin

  • URL: https://admin.greatbear.in/_/
  • First container start bootstraps the superuser from PB_SUPERUSER_EMAIL / PB_SUPERUSER_PASSWORD (templated from pb_admin_email / pb_admin_password in ansible/vars/default.yml, idempotent).

Content collections: brews, menu_categories, menu_items, experiences, testimonials, stats, pairings, settings. Reservation/contact submissions land in reservations (public create only — readable by admins).

The seed only creates records that are missing (matched by uid), so edits made in the admin are never overwritten by redeploys. Schema changes belong in pb/migrations/.

Updating the food menu

The food menu is managed in code: app/src/data/menu-data.mjs (11 categories, ~137 items, single source for the PocketBase sync and the site's offline fallback).

# 1. Edit app/src/data/menu-data.mjs, commit & push, deploy (if the image changed)
# 2. Sync the menu into PocketBase — from anywhere with admin credentials:
PB_URL=https://admin.greatbear.in \
PB_SUPERUSER_EMAIL=admin@greatbear.in \
PB_SUPERUSER_PASSWORD=... \
node scripts/update-menu.mjs

The script replaces all menu_categories / ingredients / menu_items records (deletes old, inserts new; item ingredient uids are mapped to the fresh ingredient record ids) and touches nothing else.

menu_items schema (POS-oriented)

  • uid — stable slug identity
  • category — slug → menu_categories
  • name / variant — variants are separate products whenever their price differs ("Penne Arrabiata" + variant "Veg" ₹589 / "Chicken" ₹639); the POS can group by name
  • description
  • price — number, rupees with optional paise after the decimal point (449 = ₹449, 449.5 = ₹449.50)
  • ingredients — many-to-many relation to the ingredients collection (Nuts, Dairy, Gluten, Soy, Mushroom, Egg, Chicken, Fish, Prawns, Pork, Mutton). Each ingredient has a name, an emoji icon (rendered as chips on menu cards) and a diet_class (veg/nonveg); an item's green/red FSSAI badge is derived — it shows non-veg when any linked ingredient is non-veg
  • diet — select: veg / nonveg / mixed (a bool can't be null in PocketBase, so mixed items like Caesar Salad use mixed; the site renders no badge for it)
  • pairing — optional beer pairing
  • tag — badge chip; also carries same-price choice lists (e.g. "Malai / Angara")
  • image — optional URL
  • sku — unique POS code, GB-<CAT>-<NNN> (e.g. GB-MNC-014)
  • available — false hides the item from the site (86'd) without deleting it
  • tax_category — default food-5-gst; adjust per item for POS GST mapping

Admin edits to menu records are overwritten by the next sync — treat menu-data.mjs as the menu's source of truth.

Local development (IDE)

.vscode/launch.json has run configurations for VS Code / Antigravity:

  • GreatBear (full stack) — compound: PocketBase (:8091) + Vite dev server (:3000)
  • PocketBase (dev, :8091) — serves ./pb_data with ./pb/migrations
  • Site (Vite dev, :3000) — the website against the local PocketBase
  • Site (production build + preview) — builds .output and previews it
  • Sync menu into PocketBase — runs scripts/update-menu.mjs against the local PB

Dev PocketBase runs on 8091 (8090 is occupied by another service on this machine). First-time setup is in the comments at the top of launch.json.

Container environment

Variable Default Purpose
PORT 3000 Site server port
HOST 0.0.0.0 Site server bind address
PB_URL http://127.0.0.1:8090 PocketBase URL used by the site server
PB_DATA_DIR /app/pb_data PocketBase data directory (volume)
PB_SUPERUSER_EMAIL — Initial admin (bootstrapped at start)
PB_SUPERUSER_PASSWORD — Initial admin password
S
Description
No description provided
Readme
4.9 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 53%
TypeScript 36.1%
Shell 4.8%
CSS 2.6%
Dockerfile 1.6%
Other 1.9%