podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK, 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. - Declare the same check on the hops.container unit (HealthCmd + interval, timeout, retries, start period), the convention menu-admin already uses. - 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.
Hops n Grains Microbrewery
A modern, full-stack craft microbrewery web application built with TanStack Start for the frontend and PocketBase for the backend, faithfully implementing the Google Stitch Heritage & Harvest tactile industrial design system.
Architecture Overview
hops/
├── backend/
│ ├── pocketbase # Linux AMD64 executable (WSL)
│ ├── pocketbase.exe # Windows AMD64 executable (PowerShell)
│ ├── pb_migrations/ # Database schema migrations & initial seeds
│ │ └── 1788363300_initial_schema_and_seed.js
│ └── pb_data/ # SQLite database & data storage
├── frontend/ # TanStack Start application
│ ├── src/
│ │ ├── routes/
│ │ │ ├── __root.tsx # Root layout, fonts, nav, footer, reservation context
│ │ │ ├── index.tsx # Home page (Google Stitch cinematic hero & craft overview)
│ │ │ ├── brews.tsx # Craft Brews page (Google Stitch menu with IBU/ABV filters)
│ │ │ ├── food.tsx # Global Comfort Food menu with beer pairing chips
│ │ │ ├── brewery.tsx # The Brewery & Heritage story and locations
│ │ │ └── live.tsx # Live On Tap draft board with keg telemetry
│ │ ├── components/
│ │ │ ├── Navbar.tsx # Frosted glass top bar with mobile menu drawer
│ │ │ ├── Footer.tsx # Dark industrial footer with newsletter signup
│ │ │ ├── BeerCard.tsx # Tactile craft beer card with IBU/ABV meters & pairings
│ │ │ ├── FoodCard.tsx # Food card with veg/non-veg tags & beer pairing
│ │ │ ├── ReservationModal.tsx # Interactive table reservation modal connected to PocketBase
│ │ │ └── TapHandle.tsx # Custom copper brewing handle slider from DESIGN.md
│ │ ├── lib/
│ │ │ └── pocketbase.ts # PocketBase SDK client with fallback data resilience
│ │ └── styles.css # Tailwind theme tokens, Playfair Display, Montserrat, textures
│ └── package.json
├── ansible/ # Deployment playbook (roles: register, hops, caddy)
├── Dockerfile # Multi-stage production container build
├── .containerignore # Build-context excludes (Podman/Buildah)
├── .gitignore # Git repository ignore rules
├── docker-entrypoint.sh # Process supervisor running PB & Nitro
├── Makefile # Podman build targets (build-production / build-check)
├── deploy.sh # Build & push image, then run the Ansible deploy
├── package.json # Monorepo management scripts
└── README.md
Running Locally
1. Start Both Frontend & Backend Concurrently
npm run dev
Or start them individually:
Backend (PocketBase):
# In WSL / Linux:
cd backend && ./pocketbase serve --http 127.0.0.1:8090
# In Windows PowerShell:
cd backend ; .\pocketbase.exe serve --http 127.0.0.1:8090
- API Endpoint:
http://127.0.0.1:8090 - PocketBase Admin UI:
http://127.0.0.1:8090/_/- Email:
admin@hopsngrains.com - Password:
HopsPassword1234!
- Email:
Frontend (TanStack Start):
npm run dev:frontend
- Web Application:
http://localhost:3000
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); override with PLATFORM=linux/amd64.
To run the production image locally instead:
podman build -t localhost/hops:dev .
podman run --rm -p 3000:3000 -p 8090:8090 \
-e PB_ADMIN_EMAIL=admin@hopsngrains.com -e PB_ADMIN_PASSWORD=HopsPassword1234! \
-v hops_dev_data:/app/pb_data localhost/hops:dev
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:
-
Writes
~/.config/containers/systemd/hops.network(NetworkName=hops_net) and a drop-in that attaches the shared Caddy unit to it, so Caddy can resolvehopsby container name. -
Logs in to the private registry, pulls
registry.tanshu.com/hops:{image_tag}(rollback is the same play with an older commit), writes~/podman-data/hops/.env, installs~/.config/containers/systemd/hops.container, starts it as a systemd user service and waits for the image's healthcheck (PocketBase/api/health+ the site). -
Appends the site blocks to the shared Caddyfile (
~/podman-data/caddy/conf/Caddyfile) and reloads Caddy:www.hopsngrains.com(+ apexhopsngrains.com) — frontend on port 3000, PocketBase API/admin proxied under/api/*and/_/*admin.hopsngrains.com— redirects to the PocketBase admin UI
Nothing is published on the host; Caddy reaches the container over hops_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/hops 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 emily; Caddy obtains TLS
certificates automatically on first request. This deploy starts from empty
data — the schema migration in backend/pb_migrations/ is applied on first
start, so the seed beers and food are recreated automatically.
Features & Pages
| Route | Name | Highlights |
|---|---|---|
/ |
Home | Cinematic hero matching Google Stitch mockup, brewery pillars, signature craft beer preview, call-to-action buttons. |
/brews |
Our Brews | Interactive craft beer catalog matching Stitch design, style filters (Hefeweizen, Lager, IPA, Dark Ale, Cider), and custom copper Tap Handle IBU slider. |
/food |
Global Comfort Food | Handcrafted dining menu (Wood-fired pizzas, smash burgers, truffle fries) categorized with recommended craft beer pairings and veg toggles. |
/brewery |
The Brewery | 4-step industrial brewing science walkthrough (Mashing, Boiling, Fermentation, Cellar Pouring), and taproom details for Chandigarh & Panchkula. |
/live |
Live On Tap | Real-time draft board tracking active tap numbers, ABV, IBU, and live remaining keg volume percentages. |
| Modal | Table Reservation | Interactive booking form for Sector 9 Chandigarh or Sector 5 Panchkula, submitting live to PocketBase. |