Model the build on mozimo and target emily

- Makefile: PLATFORMS, tags :<commit> and :main, build-check with --pull.
- deploy.sh: push, guards, git pull --ff-only, optional --prune, image_tag.
- Replace .containerignore with .dockerignore.
- Add the opt-in image prune to the hops role.
- Tag the Caddyfile task so --skip-tags caddy works before DNS cutover.
- Trim comments and rewrite build and deploy text in plain prose.
This commit is contained in:
tanshu committed 2026-10-20 17:47:49 +05:30
1 parent 7366596a87
commit a7c9324170
14 files changed
+192 -355

No files matched your search

-41
View File
@@ -1,41 +0,0 @@
# Keep the build context small and secret-free.
# Read by Podman/Buildah (`.containerignore`) and, as a fallback, Docker.
# VCS / editor / local state
.git
.gitignore
.containerignore
.env
.env.*
.vscode
.idea
.agents
.gemini
# Node artifacts (installed/rebuilt inside the image)
node_modules
**/node_modules
frontend/.output
frontend/.tanstack
frontend/dist
.output
dist
# PocketBase local data & binaries
backend/pb_data
backend/pocketbase
backend/pocketbase.exe
*.db
*.db-shm
*.db-wal
# Logs & scratch
*.log
npm-debug.log*
scratch
page.html
# Ops assets not needed inside the image
ansible
Makefile
README.md
+39
View File
@@ -0,0 +1,39 @@
# Dependencies. Installed inside the image.
node_modules
**/node_modules
# Build output. Produced by the builder stage.
frontend/.output
frontend/.tanstack
.output
dist
# Version control and editor.
.git
.gitignore
.dockerignore
.vscode
.idea
.agents
.gemini
# Environment and local state.
.env
.env.*
backend/pb_data
backend/pocketbase
backend/pocketbase.exe
*.db
*.db-shm
*.db-wal
# Caches and logs.
*.log
npm-debug.log*
scratch
page.html
# Repo tooling not needed inside the image.
ansible
Makefile
README.md
+5 -22
View File
@@ -1,8 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ------------------------------------------------------------------------------ # Frontend dependencies. Runs on the build platform.
# Stage 1: Install frontend dependencies (Native execution on host architecture)
# ------------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -10,9 +8,7 @@ COPY frontend/package.json frontend/package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm ci npm ci
# ------------------------------------------------------------------------------ # Frontend build. Runs on the build platform.
# Stage 2: Build the TanStack Start frontend (Native execution on host architecture)
# ------------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -22,9 +18,7 @@ COPY frontend/ ./
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN npm run build
# ------------------------------------------------------------------------------ # PocketBase binary for the target architecture. Runs on the build platform.
# Stage 3: Fetch PocketBase binary for target architecture (Native host downloader)
# ------------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader
ARG TARGETARCH ARG TARGETARCH
ARG PB_VERSION=0.40.2 ARG PB_VERSION=0.40.2
@@ -41,9 +35,7 @@ RUN apk add --no-cache curl unzip ca-certificates \
&& chmod +x /app/pocketbase \ && chmod +x /app/pocketbase \
&& rm -f /tmp/pb.zip && rm -f /tmp/pb.zip
# ------------------------------------------------------------------------------ # Runtime image. Runs on the target architecture.
# Stage 4: Production runner (Target architecture)
# ------------------------------------------------------------------------------
FROM node:lts-trixie-slim AS runner FROM node:lts-trixie-slim AS runner
LABEL maintainer="Amritanshu <docker@tanshu.com>" LABEL maintainer="Amritanshu <docker@tanshu.com>"
@@ -54,31 +46,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
# Copy PocketBase binary and migrations
COPY --from=pb-downloader /app/pocketbase /app/pocketbase COPY --from=pb-downloader /app/pocketbase /app/pocketbase
COPY backend/pb_migrations /app/pb_migrations COPY backend/pb_migrations /app/pb_migrations
# Copy compiled frontend application
COPY --from=builder /app/frontend/.output /app/frontend/.output COPY --from=builder /app/frontend/.output /app/frontend/.output
# Copy entrypoint script
COPY docker-entrypoint.sh /app/docker-entrypoint.sh COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh
# Environment variables
ENV NODE_ENV=production \ ENV NODE_ENV=production \
PORT=3000 \ PORT=3000 \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PB_DATA_DIR=/app/pb_data PB_DATA_DIR=/app/pb_data
# Persistent data volume for PocketBase SQLite database and file storage
VOLUME ["/app/pb_data"] VOLUME ["/app/pb_data"]
# Port 3000: TanStack Start Web Application
# Port 8090: PocketBase REST API & Admin UI
EXPOSE 3000 8090 EXPOSE 3000 8090
# Health check for both PocketBase and frontend server # podman drops this when it builds an OCI image. The Quadlet unit repeats it.
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8090/api/health > /dev/null && curl -fsS http://127.0.0.1:3000 > /dev/null || exit 1 CMD curl -fsS http://127.0.0.1:8090/api/health > /dev/null && curl -fsS http://127.0.0.1:3000 > /dev/null || exit 1
+14 -26
View File
@@ -1,42 +1,30 @@
# Hops n Grains — container image builds.
#
# Built with Podman/Buildah, not Docker, from the committed tree
# (`git archive HEAD`) and tagged with that commit, so a deploy ships exactly
# what git knows about. deploy.sh pushes first and refuses a dirty tree.
COMMIT ?= $(shell git rev-parse --short HEAD) COMMIT ?= $(shell git rev-parse --short HEAD)
# The deploy host (emily) is aarch64, so that is the default target: the image # emily is aarch64, so that is the default target. A foreign platform needs
# is built for the machine it runs on, not the one running make. Podman carries # the host's binfmt handlers, because podman has no built-in QEMU.
# no built-in QEMU (unlike docker buildx), so a foreign platform needs binfmt PLATFORMS ?= linux/arm64
# handlers on this host — qemu-user-binfmt is installed here. Override with e.g.
# `make build-production PLATFORM=linux/amd64`.
PLATFORM ?= linux/arm64
IMAGE ?= registry.tanshu.com/hops IMAGE ?= registry.tanshu.com/hops
.PHONY: build-production .PHONY: build-production
build-production: ## Build HEAD with podman, tag :$(COMMIT) + :latest, and push build-production: ## Build HEAD with podman, push :$(COMMIT) and :main
@echo "==> podman build --platform $(PLATFORM) → $(IMAGE):$(COMMIT) + :latest" @echo "==> podman build --platform $(PLATFORMS) -> $(IMAGE):$(COMMIT) + :main"
@git archive --format=tar HEAD | podman build \ @git archive --format=tar HEAD | podman build \
--platform $(PLATFORM) \ --platform $(PLATFORMS) \
--tag $(IMAGE):$(COMMIT) \ --tag $(IMAGE):$(COMMIT) \
--tag $(IMAGE):latest \ --tag $(IMAGE):main \
--pull=always \
- || { \ - || { \
echo "✗ podman build failed."; \ echo "podman build failed."; \
echo "Cross-platform builds need binfmt on this host (podman has no built-in QEMU):"; \ echo "Cross-platform builds need binfmt on this host (podman has no built-in QEMU):"; \
echo " sudo apt install qemu-user-binfmt # or qemu-user-static"; \ echo " sudo apt install qemu-user-static # on an x86_64 host, for $(PLATFORMS)"; \
exit 1; \ exit 1; \
} }
podman push $(IMAGE):$(COMMIT) podman push $(IMAGE):$(COMMIT)
podman push $(IMAGE):latest podman push $(IMAGE):main
.PHONY: build-check .PHONY: build-check
build-check: ## Compile check from HEAD with podman, nothing pushed build-check: ## Compile check from HEAD with podman, nothing pushed
@echo "==> podman build --platform $(PLATFORM) (no push)" @git archive --format=tar HEAD | podman build \
git archive --format=tar HEAD | podman build \ --platform $(PLATFORMS) \
--platform $(PLATFORM) \ --tag hops:check \
--tag localhost/hops:check \ --pull \
--pull=always \
- -
+54 -54
View File
@@ -4,7 +4,7 @@ A modern, full-stack craft microbrewery web application built with **TanStack St
--- ---
## Architecture Overview ## Architecture overview
``` ```
hops/ hops/
@@ -36,7 +36,7 @@ hops/
│ └── package.json │ └── package.json
├── ansible/ # Deployment playbook (roles: register, hops, caddy) ├── ansible/ # Deployment playbook (roles: register, hops, caddy)
├── Dockerfile # Multi-stage production container build ├── Dockerfile # Multi-stage production container build
├── .containerignore # Build-context excludes (Podman/Buildah) ├── .dockerignore # Build-context excludes (podman and Docker)
├── .gitignore # Git repository ignore rules ├── .gitignore # Git repository ignore rules
├── docker-entrypoint.sh # Process supervisor running PB & Nitro ├── docker-entrypoint.sh # Process supervisor running PB & Nitro
├── Makefile # Podman build targets (build-production / build-check) ├── Makefile # Podman build targets (build-production / build-check)
@@ -47,9 +47,9 @@ hops/
--- ---
## Running Locally ## Running locally
### 1. Start Both Frontend & Backend Concurrently ### 1. Start the frontend and backend together
```bash ```bash
npm run dev npm run dev
``` ```
@@ -79,32 +79,36 @@ npm run dev:frontend
## Deploying ## Deploying
The image is built with **Podman/Buildah** (not Docker) from the committed tree `deploy.sh` pushes the current branch, refuses a dirty tracked tree, and
(`git archive HEAD`) and tagged with its **commit id**, so what runs on the host requires `HEAD == origin/main`. It builds that commit with podman and runs the
is exactly what git knows about. `deploy.sh` pushes the commit first, refuses a Ansible play on emily. Log in to the registry once per machine:
dirty tracked tree and requires `HEAD == origin/main`. Log in to the registry `podman login registry.tanshu.com`.
once per machine: `podman login registry.tanshu.com`.
```bash ```bash
./deploy.sh # push, build <commit>, deploy it to emily ./deploy.sh # deploy HEAD
./deploy.sh --no-push # skip the push (you already pushed) ./deploy.sh --prune # also drop old images on emily, keep 3
./deploy.sh --force # skip the guards ./deploy.sh --force # skip the guards
``` ```
Or run the pieces yourself: The image is tagged with its commit id and `:main`. Rollback runs the same play
with an older `image_tag`:
```bash ```bash
make build-production # podman build HEAD, push :<commit> + :latest make build-production # podman build HEAD, push :<commit> and :main
make build-check # compile check from HEAD, no push make build-check # build from HEAD, push nothing
cd ansible && ansible-playbook -u tanshu playbook.yml -e "image_tag=$(git rev-parse --short HEAD)" cd ansible && ansible-playbook playbook.yml -e "image_tag=$(git rev-parse --short HEAD)"
``` ```
The image is built for **linux/arm64** by default because `emily` is aarch64. The image builds for `linux/arm64` by default because emily is aarch64. Podman
Podman has no built-in QEMU, so cross-building from an x86_64 controller needs has no built-in QEMU, so cross-building from an x86_64 controller needs binfmt
binfmt there (`qemu-user-binfmt`); override with `PLATFORM=linux/amd64`. there (`qemu-user-static`). Override with `PLATFORMS=linux/amd64`:
To run the production image locally instead: ```bash
make build-production PLATFORMS=linux/amd64
```
To run the production image locally:
```bash ```bash
podman build -t localhost/hops:dev . podman build -t localhost/hops:dev .
@@ -115,55 +119,51 @@ podman run --rm -p 3000:3000 -p 8090:8090 \
### Ansible deployment ### Ansible deployment
Requirements on the controller: `ansible` (no collections — the play uses The controller needs `ansible`. The target host needs the shared Caddy rootless
built-in modules plus the `podman` / `systemctl --user` CLI). The target host Quadlet setup from the `ansible-playbooks` repo. That playbook owns the Caddy
must already run the fleet's shared Caddy **rootless Quadlet** setup: the unit, the rootless prerequisites, and the Caddyfile this play appends to.
`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`), The play targets `emily`, connects as the unprivileged container user, and does
connects as the unprivileged container user and **does not `become`**: the not use `become`. The Quadlet units belong to that user, and the SSH session's
Quadlet units belong to that user, and the SSH connection's PAM session is what PAM context makes `systemctl --user` work.
makes `systemctl --user` work.
```bash ```bash
cd ansible cd ansible
ansible-playbook -u tanshu playbook.yml -e "image_tag=<commit>" ansible-playbook playbook.yml -e "image_tag=<commit>"
``` ```
All deployment variables live in `ansible/vars/default.yml` (registry, image Skip the Caddyfile update until DNS points at emily. Otherwise Caddy retries
tag, domains, PocketBase admin credentials). The play: certificate issuance for a name that still resolves to monoco:
1. Writes `~/.config/containers/systemd/hops.network` ```bash
(`NetworkName=hops_net`) and a drop-in that attaches the shared Caddy unit to ansible-playbook playbook.yml -e "image_tag=<commit>" --skip-tags caddy
it, so Caddy can resolve `hops` by container name. ```
2. 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).
3. Appends the site blocks to the shared Caddyfile
(`~/podman-data/caddy/conf/Caddyfile`) and reloads Caddy:
- `www.hopsngrains.com` (+ apex `hopsngrains.com`) — frontend on port 3000, Deployment variables live in `ansible/vars/default.yml`. The play:
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`. 1. Writes `~/.config/containers/systemd/hops.network` with `NetworkName=hops_net`,
plus a drop-in that attaches the shared Caddy unit to it. Caddy can then
resolve `hops` by container name.
2. Logs in to the registry, pulls `registry.tanshu.com/hops:{image_tag}`, writes
`~/podman-data/hops/.env`, installs `hops.container`, starts it as a systemd
user service, and waits for the healthcheck.
3. Appends the site blocks to `~/podman-data/caddy/conf/Caddyfile` and reloads
Caddy. `www.hopsngrains.com` and the apex serve the frontend on port 3000 and
proxy `/api/*` and `/_/*` to PocketBase. `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 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 `UserNS=keep-id:uid=0,gid=0`. Files under `~/podman-data/hops` stay owned by
PocketBase's files need no `podman unshare` to inspect or back up. `tanshu`, and PocketBase's files need no `podman unshare` to read or back up.
DNS records for all three hosts must point at `emily`; Caddy obtains TLS DNS for all three hosts must point at emily before Caddy can issue certificates.
certificates automatically on first request. This deploy starts from empty The first deploy starts with empty data. PocketBase applies the schema migration
data — the schema migration in `backend/pb_migrations/` is applied on first in `backend/pb_migrations/` on first boot and recreates the seed beers and food.
start, so the seed beers and food are recreated automatically.
--- ---
## Features & Pages ## Features and pages
| Route | Name | Highlights | | Route | Name | Highlights |
|---|---|---| |---|---|---|
+7 -23
View File
@@ -1,18 +1,7 @@
--- ---
# Hops n Grains — rootless Podman + Quadlet, behind the fleet's shared Caddy # Hops n Grains as a rootless Podman Quadlet on emily, behind the shared Caddy.
# unit (caddy/playbook.yml in the ansible-playbooks repo). # Run: ansible-playbook playbook.yml -e image_tag=<commit>
# # Use --skip-tags caddy until DNS for hopsngrains.com points at emily.
# Connects as the container user and runs everything as that user: the Quadlet
# units belong to it, so the connection's PAM session is what makes
# `systemctl --user` and `podman exec` work without any environment juggling.
# There is deliberately no `become`: root-only prerequisites (Podman,
# `loginctl enable-linger`, the low-port sysctl Caddy needs) belong to
# caddy/playbook.yml, which must have run on this host first.
#
# gather_facts is required: podman_user, podman_data_dir and quadlet_dir are
# derived from the user_id / user_dir facts.
#
# Run with: ansible-playbook -u tanshu playbook.yml
- name: Deploy Hops n Grains as a rootless Podman Quadlet - name: Deploy Hops n Grains as a rootless Podman Quadlet
hosts: emily hosts: emily
gather_facts: true gather_facts: true
@@ -20,17 +9,12 @@
- vars/default.yml - vars/default.yml
pre_tasks: pre_tasks:
# This must happen before the app unit is started, not after: the generated # Quadlet fails unit generation when hops.network is missing, so write the
# hops.container references {{ network_name }}.network, and Quadlet fails # network and the Caddy drop-in before the app unit starts.
# *unit generation* (so `systemctl --user start` reports "unit not found") - name: Register the Hops n Grains network with Caddy
# when a referenced .network file does not exist yet. The same include
# writes the drop-in that attaches the shared Caddy unit to this network,
# which is what makes `hops` resolvable to Caddy.
- name: Register Hops n Grains' network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/register/tasks/main.yaml" ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/register/tasks/main.yaml"
roles: roles:
# The app: data directory, .env, the Quadlet unit, start + healthcheck.
- hops - hops
# Appends this site's blocks to the shared Caddyfile and reloads Caddy. # The Caddyfile task is tagged caddy. Skip it until DNS points here.
- caddy - caddy
+3 -17
View File
@@ -1,27 +1,13 @@
--- ---
# Caddy is its own playbook (caddy/playbook.yml in the ansible-playbooks repo) # A reload keeps existing connections. A restart would drop every hosted site.
# and a rootless Quadlet unit there. These handlers run as the connection user,
# so `systemctl --user` and `podman exec` work without any XDG_RUNTIME_DIR
# juggling.
# Notified by roles/caddy when the Caddyfile changes. A reload keeps existing
# connections; a restart would briefly drop every hosted site.
#
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
# not {{ caddyfile_path }}, which is the host side of it.
- name: Reload Caddy configuration - name: Reload Caddy configuration
ansible.builtin.command: >- ansible.builtin.command: >-
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
changed_when: true changed_when: true
listen: "Reload Caddy" listen: "Reload Caddy"
# Notified by roles/register when this service's network unit, or the drop-in # Notified when the network drop-in changes. Quadlet cannot add a network to a
# that attaches Caddy to it, is written. Quadlet has no live equivalent of # running container, so Caddy must be recreated.
# `docker network connect`, so the container has to be recreated — a brief
# restart of every hosted site, not just this one.
#
# Defined after the reload above so that when both are notified the cheap config
# reload happens first.
- name: Restart Caddy - name: Restart Caddy
ansible.builtin.systemd_service: ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service" name: "{{ caddy_name }}.service"
+3 -10
View File
@@ -1,16 +1,9 @@
--- ---
# Append Hops n Grains' site blocks to the shared Caddyfile. # Append the site blocks to the shared Caddyfile. The file is bind-mounted into
# # Caddy, so the reload handler is enough. blockinfile is idempotent.
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
# change as soon as the file is written; the handler only asks Caddy to re-read
# it. That is a reload, not a restart, so the other hosted sites are untouched.
#
# blockinfile is idempotent on its own via the marker, so there is no need to
# slurp the file and compare first — which also matters because slurp *fails*
# when the Caddyfile does not exist yet, on a host where caddy/playbook.yml has
# not run.
- name: "Add the {{ host }} site blocks to the Caddyfile" - name: "Add the {{ host }} site blocks to the Caddyfile"
tags: [caddy]
ansible.builtin.blockinfile: ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}" path: "{{ caddyfile_path }}"
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
+1 -2
View File
@@ -1,6 +1,5 @@
--- ---
# All handlers run as the connection user ({{ podman_user }}), so # Handlers run as the connection user, so systemctl --user works as is.
# `systemctl --user` works without setting XDG_RUNTIME_DIR.
- name: Reload systemd user units - name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload ansible.builtin.command: systemctl --user daemon-reload
+20 -35
View File
@@ -1,14 +1,7 @@
--- ---
# Hops n Grains as a rootless Podman Quadlet unit. # Hops n Grains as a rootless Podman Quadlet. Runs as {{ podman_user }}.
# # The SSH session's PAM context makes systemctl --user work without extra setup.
# Everything here runs as {{ podman_user }} — the connection user. No become, no
# XDG_RUNTIME_DIR: the SSH connection gives a real PAM session, so systemd's
# user manager is reachable through the standard environment.
# The registry is private, so the pull below has to be authenticated. Podman
# keeps the credential in the user's own auth file; it is removed again at the
# end of this role, so nothing but the image is left behind. Skipped when no
# credentials are configured.
- name: Log in to the private registry - name: Log in to the private registry
ansible.builtin.command: ansible.builtin.command:
cmd: "podman login --username {{ registry_username }} --password-stdin {{ registry }}" cmd: "podman login --username {{ registry_username }} --password-stdin {{ registry }}"
@@ -19,12 +12,19 @@
- registry_username | length > 0 - registry_username | length > 0
- registry_password | length > 0 - registry_password | length > 0
# Pulled here rather than left to the unit: Quadlet only pulls when the image is # Pull here. Quadlet only pulls a missing image, and the unit has no credential.
# missing, and at that point the service has no registry credential to offer.
- name: Pull the Hops n Grains image - name: Pull the Hops n Grains image
ansible.builtin.command: "podman pull {{ image }}" ansible.builtin.command: "podman pull {{ image }}"
changed_when: false changed_when: false
# Opt-in via ./deploy.sh --prune. Keeps the three newest images.
- name: Prune old images
ansible.builtin.shell: >
podman images -q {{ image_name }} | tail -n +4 | xargs -r podman rmi
when: hops_image_prune | default(false) | bool
failed_when: false
changed_when: true
- name: Ensure the PocketBase data directory exists - name: Ensure the PocketBase data directory exists
ansible.builtin.file: ansible.builtin.file:
path: "{{ host_directory }}/pb_data" path: "{{ host_directory }}/pb_data"
@@ -33,9 +33,7 @@
group: "{{ podman_user }}" group: "{{ podman_user }}"
mode: '0755' mode: '0755'
# Secrets (the PocketBase superuser) and the run-time configuration are read by # systemd reads this file through EnvironmentFile. It holds the admin password.
# systemd via EnvironmentFile=, not by Ansible, so this has to be written before
# the unit starts. Mode 0600: it holds the admin password.
- name: Write the environment file - name: Write the environment file
ansible.builtin.template: ansible.builtin.template:
src: hops.env.j2 src: hops.env.j2
@@ -65,10 +63,8 @@
state: started state: started
daemon_reload: true daemon_reload: true
rescue: rescue:
# systemd reports "control process exited with error code" and nothing else, # systemd reports only the exit code. Collect the real reason.
# which is useless for the usual failures (a missing image, an unattached - name: Collect diagnostics
# network, an unwritable data directory). Pull the real reason out.
- name: Collect Hops n Grains diagnostics
ansible.builtin.command: "{{ item }}" ansible.builtin.command: "{{ item }}"
loop: loop:
- "systemctl --user status {{ unit_name }} --no-pager" - "systemctl --user status {{ unit_name }} --no-pager"
@@ -78,36 +74,26 @@
changed_when: false changed_when: false
failed_when: false failed_when: false
- name: Show Hops n Grains diagnostics - name: Show diagnostics
ansible.builtin.debug: ansible.builtin.debug:
msg: "{{ hops_diag.results | map(attribute='stdout') | list }}" msg: "{{ hops_diag.results | map(attribute='stdout') | list }}"
- name: Fail - name: Fail
ansible.builtin.fail: ansible.builtin.fail:
msg: "Hops n Grains did not start — see the diagnostics above." msg: "Hops n Grains did not start. See the diagnostics above."
# The unit template notifies "Restart Hops n Grains", but handlers otherwise run # Handlers run at the end of the play. Flush them so the healthcheck gate
# at the very end of the play — after the healthcheck gate below. Flush them # tests the container with the current unit.
# here so the gate tests the container that has the current HealthCmd; without
# this, a run that only changes the unit would check the stale container and
# fail. On a run where nothing changed, no handler is pending and this is a
# no-op.
- name: Apply pending unit changes before verifying - name: Apply pending unit changes before verifying
ansible.builtin.meta: flush_handlers ansible.builtin.meta: flush_handlers
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
- name: Verify the Hops n Grains unit is active - name: Verify the Hops n Grains unit is active
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: hops_active register: hops_active
changed_when: false changed_when: false
failed_when: hops_active.stdout != 'active' failed_when: hops_active.stdout != 'active'
# `is-active` says the unit is running, not that both processes inside the # is-active does not prove both processes are healthy. Force a healthcheck.
# container are healthy: the entrypoint starts PocketBase, waits for
# /api/health and upserts the superuser before the site server answers. The
# image ships a HEALTHCHECK covering both; `healthcheck run` forces one now and
# reports its result instead of waiting out the interval.
- name: Wait for the container healthcheck to pass - name: Wait for the container healthcheck to pass
ansible.builtin.command: "podman healthcheck run {{ container_name }}" ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: hops_health register: hops_health
@@ -131,8 +117,7 @@
{{ container_name }} is on {{ container_name }} is on
{{ hops_networks_now.stdout | default('(container not running)') }} {{ hops_networks_now.stdout | default('(container not running)') }}
# Removes the credential from the user's auth file. The image is already local # The image is local and the unit only pulls a missing image.
# and the unit only pulls when it is missing, so this does not break restarts.
- name: Log out of the private registry - name: Log out of the private registry
ansible.builtin.command: "podman logout {{ registry }}" ansible.builtin.command: "podman logout {{ registry }}"
changed_when: false changed_when: false
+9 -20
View File
@@ -1,9 +1,5 @@
# Hops n Grains — rootless Podman Quadlet unit. # Hops n Grains rootless Quadlet unit. Managed by Ansible.
# Managed by Ansible (roles/hops). Do not edit by hand. # Joins {{ network_name }}.network so Caddy resolves {{ container_name }}.
#
# This unit joins {{ network_name }}.network, which is created by
# roles/register — the same include that attaches the shared Caddy unit to it,
# so Caddy can resolve {{ container_name }} by name.
[Unit] [Unit]
Description=Hops n Grains microbrewery (PocketBase + TanStack Start) Description=Hops n Grains microbrewery (PocketBase + TanStack Start)
@@ -14,27 +10,21 @@ Wants=network-online.target
ContainerName={{ container_name }} ContainerName={{ container_name }}
Image={{ image }} Image={{ image }}
# The image runs as root: docker-entrypoint.sh starts PocketBase, bootstraps the # The image runs as root. keep-id maps that onto {{ podman_user }}, so
# superuser and then starts the site server, and the PocketBase data files it # {{ host_directory }} stays owned by the host user.
# writes are root-owned. Mapping container root onto {{ podman_user }} keeps
# {{ host_directory }} readable and writable by both the container and the host
# user, and leaves no subuid-owned files to trip over backups.
UserNS=keep-id:uid=0,gid=0 UserNS=keep-id:uid=0,gid=0
# NODE_ENV, PORT, PB_URL and the PocketBase superuser live here. Read by # systemd reads the env file. It must exist before the unit starts.
# systemd, not by Ansible, so the file has to exist before this unit starts.
EnvironmentFile={{ host_directory }}/.env EnvironmentFile={{ host_directory }}/.env
Network={{ network_name }}.network Network={{ network_name }}.network
Volume={{ host_directory }}/pb_data:/app/pb_data Volume={{ host_directory }}/pb_data:/app/pb_data
# No PublishPort on purpose: the Docker-era deployment had none either, and # No PublishPort. Caddy reaches both ports over {{ network_name }}_net.
# Caddy reaches both ports over {{ network_name }}_net.
# podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK # podman builds OCI images by default and drops the Dockerfile HEALTHCHECK,
# instruction, so the same check is declared here and the play gates on it with # so declare the check here.
# `podman healthcheck run`. curl is in the runner image.
HealthCmd={{ health_cmd }} HealthCmd={{ health_cmd }}
HealthInterval=15s HealthInterval=15s
HealthTimeout=3s HealthTimeout=3s
@@ -43,8 +33,7 @@ HealthStartPeriod=5s
[Service] [Service]
Restart=always Restart=always
# The entrypoint waits for PocketBase and upserts the superuser before the site # The entrypoint waits for PocketBase and the superuser before the site answers.
# server answers, so allow generously for a large database.
TimeoutStartSec=300 TimeoutStartSec=300
[Install] [Install]
+6 -27
View File
@@ -1,18 +1,6 @@
--- ---
# Register Hops n Grains' network with the shared Caddy unit. # Write hops.network and the Caddy drop-in that joins Caddy to it.
# # Run from pre_tasks, before the app unit is generated.
# Caddy is the base unit (caddy/playbook.yml in the ansible-playbooks repo) and
# knows nothing about its upstreams. A service adds itself by writing two files
# into the Quadlet directory:
#
# 1. its own hops.network unit, and
# 2. a drop-in next to caddy.container that pulls that network into Caddy.
#
# This mirrors ansible-playbooks/caddy/roles/register/tasks/main.yaml, vendored
# so this repo's deploy.sh stands on its own. Keep the bytes in sync with it.
#
# Included from playbook.yml's pre_tasks, before the app unit is generated. Runs
# as the connection user, which must be {{ podman_user }}.
- name: Ensure the Quadlet directory exists - name: Ensure the Quadlet directory exists
ansible.builtin.file: ansible.builtin.file:
@@ -29,15 +17,8 @@
group: "{{ podman_user }}" group: "{{ podman_user }}"
mode: '0644' mode: '0644'
content: | content: |
# {{ network_name }} — logical app network. # {{ network_name }} network unit. Managed by Ansible.
# Managed by Ansible. Do not edit by hand. # NetworkName pins the Podman name to {{ network_name }}_net.
#
# NetworkName pins the Podman network name. Without it Quadlet would
# create "systemd-{{ network_name }}".
#
# isolate blocks traffic between separate Podman networks, so a container
# on this network cannot reach one that is only on another. Caddy still
# reaches it, because it is a member of it.
[Network] [Network]
NetworkName={{ network_name }}_net NetworkName={{ network_name }}_net
Options=isolate=true Options=isolate=true
@@ -58,10 +39,8 @@
group: "{{ podman_user }}" group: "{{ podman_user }}"
mode: '0644' mode: '0644'
content: | content: |
# Added by Hops n Grains' playbook. Do not edit by hand. # Added by the Hops n Grains playbook. Managed by Ansible.
# # Quadlet merges this into caddy.container.
# Quadlet merges this into caddy.container. Deleting this file removes
# the network from Caddy on the next daemon-reload.
[Container] [Container]
Network={{ network_name }}.network Network={{ network_name }}.network
notify: Restart Caddy notify: Restart Caddy
+12 -34
View File
@@ -1,67 +1,47 @@
--- ---
# --------------------------------------------------------------------------- # Rootless Podman and Quadlet on emily. The play runs as the connecting user,
# Rootless Podman / Quadlet # so these paths follow that user's home.
# ---------------------------------------------------------------------------
# Derived from the connection: this play carries no become, so the Quadlet
# units belong to whoever Ansible connects as. Requires gather_facts (set in
# playbook.yml). A host that has already run caddy/playbook.yml from the
# ansible-playbooks repo has the rootless prerequisites.
podman_user: "{{ ansible_facts['user_id'] }}" podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Image
# ---------------------------------------------------------------------------
registry: registry.tanshu.com registry: registry.tanshu.com
registry_username: ta-registry registry_username: ta-registry
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
image_name: "{{ registry }}/hops" image_name: "{{ registry }}/hops"
tag: latest # deploy.sh passes image_tag=<commit>. Rollback uses an older commit.
# deploy.sh passes -e image_tag=<commit>; `tag` is the fallback when running the tag: main
# play by hand. Rollback = -e image_tag=<older-commit>.
image: "{{ image_name }}:{{ image_tag | default(tag) }}" image: "{{ image_name }}:{{ image_tag | default(tag) }}"
# ---------------------------------------------------------------------------
# Hops n Grains
# ---------------------------------------------------------------------------
app_name: hops app_name: hops
container_name: "{{ app_name }}" container_name: "{{ app_name }}"
unit_name: "{{ container_name }}.service" unit_name: "{{ container_name }}.service"
# The Quadlet file is <network_name>.network; roles/register pins the Podman # The register role pins NetworkName to hops_net.
# network name to <network_name>_net.
network_name: "{{ app_name }}" network_name: "{{ app_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}" host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# Ports inside the container. Caddy proxies to them over hops_net; nothing is # Caddy reaches both ports over hops_net. Nothing is published on the host.
# published on the host.
site_port: 3000 site_port: 3000
pb_port: 8090 pb_port: 8090
# The Dockerfile's HEALTHCHECK is dropped when podman builds an OCI image (its # podman builds OCI images by default and drops the Dockerfile HEALTHCHECK,
# default), so the same check is declared on the Quadlet unit and the play gates # so the unit declares the check and the play gates on it.
# on `podman healthcheck run`. curl is in the runner image.
health_cmd: "curl -fsS http://127.0.0.1:{{ pb_port }}/api/health > /dev/null && curl -fsS http://127.0.0.1:{{ site_port }} > /dev/null" health_cmd: "curl -fsS http://127.0.0.1:{{ pb_port }}/api/health > /dev/null && curl -fsS http://127.0.0.1:{{ site_port }} > /dev/null"
# Public hosts for frontend & PocketBase routing.
host: www.hopsngrains.com host: www.hopsngrains.com
apex_host: hopsngrains.com apex_host: hopsngrains.com
admin_host: admin.hopsngrains.com admin_host: admin.hopsngrains.com
# ---------------------------------------------------------------------------
# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo)
# ---------------------------------------------------------------------------
caddy_name: caddy caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
# Appended to the shared Caddyfile by roles/caddy. The sites are served from # Appended to the shared Caddyfile by roles/caddy.
# the container name over hops_net; no host ports are involved.
snippet_block: | snippet_block: |
{{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} { {{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} {
# PocketBase Admin UI and REST API # PocketBase admin UI and REST API
redir /_ /_/ redir /_ /_/
handle /_/* { handle /_/* {
reverse_proxy {{ container_name }}:{{ pb_port }} reverse_proxy {{ container_name }}:{{ pb_port }}
@@ -70,7 +50,7 @@ snippet_block: |
reverse_proxy {{ container_name }}:{{ pb_port }} reverse_proxy {{ container_name }}:{{ pb_port }}
} }
# TanStack Start frontend application # TanStack Start frontend
handle { handle {
reverse_proxy {{ container_name }}:{{ site_port }} reverse_proxy {{ container_name }}:{{ site_port }}
} }
@@ -82,8 +62,6 @@ snippet_block: |
} }
{% endif %} {% endif %}
# --------------------------------------------------------------------------- # Bootstrapped on first start.
# PocketBase initial superuser credentials (bootstrapped on first start)
# ---------------------------------------------------------------------------
pb_admin_email: admin@hopsngrains.com pb_admin_email: admin@hopsngrains.com
pb_admin_password: HopsPassword1234! pb_admin_password: HopsPassword1234!
+19 -44
View File
@@ -1,72 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# # Deploy HEAD with podman and ansible on emily. Flags: --prune keeps three old
# Hops n Grains deploy — push the current commit, build it with podman, and # images, --force skips the guards. The tree must be clean and HEAD pushed.
# install the Quadlet unit on emily with ansible.
#
# The image is built 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 deploy HEAD (tracked tree must be clean and pushed)
# ./deploy.sh --force skip the guards (know what you are doing)
# ./deploy.sh --no-push skip the push, if you already pushed
#
# Log in to the registry once beforehand: podman login registry.tanshu.com
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
BRANCH="${BRANCH:-main}" git push
FORCE=false FORCE=false
NO_PUSH=false PRUNE=false
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--force) FORCE=true ;; --force) FORCE=true ;;
--no-push) NO_PUSH=true ;; --prune) PRUNE=true ;;
*) *)
echo "Usage: $0 [--force] [--no-push]" >&2 echo "Usage: $0 [--force] [--prune]" >&2
exit 1 exit 1
;; ;;
esac esac
done done
# The build takes HEAD, so push first: otherwise the registry collects an image
# for a commit that exists only on this machine.
if [ "$NO_PUSH" != true ]; then
echo "==> pushing"
git push
fi
# Guards. Only tracked-file state matters — untracked and ignored files never
# enter a `git archive HEAD` build. HEAD must match origin/$BRANCH so the commit
# the image is tagged with is one the remote actually has.
if [ "$FORCE" != true ]; then if [ "$FORCE" != true ]; then
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "x tracked files modified - commit or stash first (or --force)" >&2 echo "x tracked files modified, commit or stash first (or --force)" >&2
exit 1 exit 1
fi fi
git fetch origin --quiet git fetch origin --quiet
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$BRANCH")" ]; then if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "x HEAD != origin/$BRANCH - push or pull first (or --force)" >&2 echo "x HEAD != origin/main, push or pull first (or --force)" >&2
exit 1 exit 1
fi fi
fi fi
git pull --ff-only
TAG=$(git rev-parse --short HEAD) TAG=$(git rev-parse --short HEAD)
echo "==> deploying commit $TAG" echo "==> deploying commit $TAG"
echo "========================================================"
echo " Building & Uploading Container Image (Podman)"
echo "========================================================"
make build-production COMMIT="$TAG" make build-production COMMIT="$TAG"
echo "" if [ "$PRUNE" = true ]; then
echo "========================================================" PRUNE_FLAG="-e hops_image_prune=true"
echo " Executing Ansible Deployment" else
echo "========================================================" PRUNE_FLAG=""
# The play configures rootless Podman Quadlet units that belong to the fi
# connecting user, so it must connect as that user and must NOT become root.
# `-u` is the account that owns ~/.config/containers/systemd on the target. cd ansible
deploy_user="${ANSIBLE_USER:-tanshu}" # shellcheck disable=SC2086
cd ansible || exit ansible-playbook playbook.yml -e "image_tag=$TAG" $PRUNE_FLAG
# image_tag pins the exact commit; rollback is -e image_tag=<older-commit>.
ansible-playbook -u "$deploy_user" playbook.yml -e "image_tag=$TAG"