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
# ------------------------------------------------------------------------------
# Stage 1: Install frontend dependencies (Native execution on host architecture)
# ------------------------------------------------------------------------------
# Frontend dependencies. Runs on the build platform.
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps
WORKDIR /app/frontend
@@ -10,9 +8,7 @@ COPY frontend/package.json frontend/package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# ------------------------------------------------------------------------------
# Stage 2: Build the TanStack Start frontend (Native execution on host architecture)
# ------------------------------------------------------------------------------
# Frontend build. Runs on the build platform.
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder
WORKDIR /app/frontend
@@ -22,9 +18,7 @@ COPY frontend/ ./
ENV NODE_ENV=production
RUN npm run build
# ------------------------------------------------------------------------------
# Stage 3: Fetch PocketBase binary for target architecture (Native host downloader)
# ------------------------------------------------------------------------------
# PocketBase binary for the target architecture. Runs on the build platform.
FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader
ARG TARGETARCH
ARG PB_VERSION=0.40.2
@@ -41,9 +35,7 @@ RUN apk add --no-cache curl unzip ca-certificates \
&& chmod +x /app/pocketbase \
&& rm -f /tmp/pb.zip
# ------------------------------------------------------------------------------
# Stage 4: Production runner (Target architecture)
# ------------------------------------------------------------------------------
# Runtime image. Runs on the target architecture.
FROM node:lts-trixie-slim AS runner
LABEL maintainer="Amritanshu <docker@tanshu.com>"
@@ -54,31 +46,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
# Copy PocketBase binary and migrations
COPY --from=pb-downloader /app/pocketbase /app/pocketbase
COPY backend/pb_migrations /app/pb_migrations
# Copy compiled frontend application
COPY --from=builder /app/frontend/.output /app/frontend/.output
# Copy entrypoint script
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
# Environment variables
ENV NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0 \
PB_DATA_DIR=/app/pb_data
# Persistent data volume for PocketBase SQLite database and file storage
VOLUME ["/app/pb_data"]
# Port 3000: TanStack Start Web Application
# Port 8090: PocketBase REST API & Admin UI
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 \
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)
# The deploy host (emily) is aarch64, so that is the default target: the image
# is built for the machine it runs on, not the one running make. Podman carries
# no built-in QEMU (unlike docker buildx), so a foreign platform needs binfmt
# handlers on this host — qemu-user-binfmt is installed here. Override with e.g.
# `make build-production PLATFORM=linux/amd64`.
PLATFORM ?= linux/arm64
# emily is aarch64, so that is the default target. A foreign platform needs
# the host's binfmt handlers, because podman has no built-in QEMU.
PLATFORMS ?= linux/arm64
IMAGE ?= registry.tanshu.com/hops
.PHONY: build-production
build-production: ## Build HEAD with podman, tag :$(COMMIT) + :latest, and push
@echo "==> podman build --platform $(PLATFORM) → $(IMAGE):$(COMMIT) + :latest"
build-production: ## Build HEAD with podman, push :$(COMMIT) and :main
@echo "==> podman build --platform $(PLATFORMS) -> $(IMAGE):$(COMMIT) + :main"
@git archive --format=tar HEAD | podman build \
--platform $(PLATFORM) \
--platform $(PLATFORMS) \
--tag $(IMAGE):$(COMMIT) \
--tag $(IMAGE):latest \
--pull=always \
--tag $(IMAGE):main \
- || { \
echo "✗ podman build failed."; \
echo "podman build failed."; \
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; \
}
podman push $(IMAGE):$(COMMIT)
podman push $(IMAGE):latest
podman push $(IMAGE):main
.PHONY: build-check
build-check: ## Compile check from HEAD with podman, nothing pushed
@echo "==> podman build --platform $(PLATFORM) (no push)"
git archive --format=tar HEAD | podman build \
--platform $(PLATFORM) \
--tag localhost/hops:check \
--pull=always \
@git archive --format=tar HEAD | podman build \
--platform $(PLATFORMS) \
--tag hops:check \
--pull \
-
+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/
@@ -36,7 +36,7 @@ hops/
│ └── package.json
├── ansible/ # Deployment playbook (roles: register, hops, caddy)
├── Dockerfile # Multi-stage production container build
├── .containerignore # Build-context excludes (Podman/Buildah)
├── .dockerignore # Build-context excludes (podman and Docker)
├── .gitignore # Git repository ignore rules
├── docker-entrypoint.sh # Process supervisor running PB & Nitro
├── 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
npm run dev
```
@@ -79,32 +79,36 @@ npm run dev:frontend
## 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` pushes the current branch, refuses a dirty tracked tree, and
requires `HEAD == origin/main`. It builds that commit with podman and runs the
Ansible play on emily. Log in to the registry once per machine:
`podman login registry.tanshu.com`.
```bash
./deploy.sh # push, build <commit>, deploy it to emily
./deploy.sh --no-push # skip the push (you already pushed)
./deploy.sh # deploy HEAD
./deploy.sh --prune # also drop old images on emily, keep 3
./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
make build-production # podman build HEAD, push :<commit> + :latest
make build-check # compile check from HEAD, no push
make build-production # podman build HEAD, push :<commit> and :main
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.
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`.
The image builds 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-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
podman build -t localhost/hops:dev .
@@ -115,55 +119,51 @@ podman run --rm -p 3000:3000 -p 8090:8090 \
### 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 controller needs `ansible`. The target host needs the shared Caddy rootless
Quadlet setup from the `ansible-playbooks` repo. That playbook owns the Caddy
unit, the rootless prerequisites, 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.
The play targets `emily`, connects as the unprivileged container user, and does
not use `become`. The Quadlet units belong to that user, and the SSH session's
PAM context makes `systemctl --user` work.
```bash
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
tag, domains, PocketBase admin credentials). The play:
Skip the Caddyfile update until DNS points at emily. Otherwise Caddy retries
certificate issuance for a name that still resolves to monoco:
1. Writes `~/.config/containers/systemd/hops.network`
(`NetworkName=hops_net`) and a drop-in that attaches the shared Caddy unit to
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:
```bash
ansible-playbook playbook.yml -e "image_tag=<commit>" --skip-tags caddy
```
- `www.hopsngrains.com` (+ apex `hopsngrains.com`) — frontend on port 3000,
PocketBase API/admin proxied under `/api/*` and `/_/*`
- `admin.hopsngrains.com` — redirects to the PocketBase admin UI
Deployment variables live in `ansible/vars/default.yml`. The play:
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
`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.
`UserNS=keep-id:uid=0,gid=0`. Files under `~/podman-data/hops` stay owned by
`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
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.
DNS for all three hosts must point at emily before Caddy can issue certificates.
The first deploy starts with empty data. PocketBase applies the schema migration
in `backend/pb_migrations/` on first boot and recreates the seed beers and food.
---
## Features & Pages
## Features and pages
| Route | Name | Highlights |
|---|---|---|
+7 -23
View File
@@ -1,18 +1,7 @@
---
# Hops n Grains — rootless Podman + Quadlet, behind the fleet's shared Caddy
# unit (caddy/playbook.yml in the ansible-playbooks repo).
#
# 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
# Hops n Grains as a rootless Podman Quadlet on emily, behind the shared Caddy.
# Run: ansible-playbook playbook.yml -e image_tag=<commit>
# Use --skip-tags caddy until DNS for hopsngrains.com points at emily.
- name: Deploy Hops n Grains as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -20,17 +9,12 @@
- vars/default.yml
pre_tasks:
# This must happen before the app unit is started, not after: the generated
# hops.container references {{ network_name }}.network, and Quadlet fails
# *unit generation* (so `systemctl --user start` reports "unit not found")
# 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
# Quadlet fails unit generation when hops.network is missing, so write the
# network and the Caddy drop-in before the app unit starts.
- name: Register the Hops n Grains network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/register/tasks/main.yaml"
roles:
# The app: data directory, .env, the Quadlet unit, start + healthcheck.
- 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
+3 -17
View File
@@ -1,27 +1,13 @@
---
# Caddy is its own playbook (caddy/playbook.yml in the ansible-playbooks repo)
# 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.
# A reload keeps existing connections. A restart would drop every hosted site.
- name: Reload Caddy configuration
ansible.builtin.command: >-
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
changed_when: true
listen: "Reload Caddy"
# Notified by roles/register when this service's network unit, or the drop-in
# that attaches Caddy to it, is written. Quadlet has no live equivalent of
# `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.
# Notified when the network drop-in changes. Quadlet cannot add a network to a
# running container, so Caddy must be recreated.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
+3 -10
View File
@@ -1,16 +1,9 @@
---
# Append Hops n Grains' site blocks to the shared Caddyfile.
#
# 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.
# Append the site blocks to the shared Caddyfile. The file is bind-mounted into
# Caddy, so the reload handler is enough. blockinfile is idempotent.
- name: "Add the {{ host }} site blocks to the Caddyfile"
tags: [caddy]
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
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
# `systemctl --user` works without setting XDG_RUNTIME_DIR.
# Handlers run as the connection user, so systemctl --user works as is.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
+20 -35
View File
@@ -1,14 +1,7 @@
---
# Hops n Grains as a rootless Podman Quadlet unit.
#
# 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.
# 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.
# 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
ansible.builtin.command:
cmd: "podman login --username {{ registry_username }} --password-stdin {{ registry }}"
@@ -19,12 +12,19 @@
- registry_username | length > 0
- registry_password | length > 0
# Pulled here rather than left to the unit: Quadlet only pulls when the image is
# missing, and at that point the service has no registry credential to offer.
# Pull here. Quadlet only pulls a missing image, and the unit has no credential.
- name: Pull the Hops n Grains image
ansible.builtin.command: "podman pull {{ image }}"
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
ansible.builtin.file:
path: "{{ host_directory }}/pb_data"
@@ -33,9 +33,7 @@
group: "{{ podman_user }}"
mode: '0755'
# Secrets (the PocketBase superuser) and the run-time configuration are read by
# systemd via EnvironmentFile=, not by Ansible, so this has to be written before
# the unit starts. Mode 0600: it holds the admin password.
# systemd reads this file through EnvironmentFile. It holds the admin password.
- name: Write the environment file
ansible.builtin.template:
src: hops.env.j2
@@ -65,10 +63,8 @@
state: started
daemon_reload: true
rescue:
# systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures (a missing image, an unattached
# network, an unwritable data directory). Pull the real reason out.
- name: Collect Hops n Grains diagnostics
# systemd reports only the exit code. Collect the real reason.
- name: Collect diagnostics
ansible.builtin.command: "{{ item }}"
loop:
- "systemctl --user status {{ unit_name }} --no-pager"
@@ -78,36 +74,26 @@
changed_when: false
failed_when: false
- name: Show Hops n Grains diagnostics
- name: Show diagnostics
ansible.builtin.debug:
msg: "{{ hops_diag.results | map(attribute='stdout') | list }}"
- name: 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
# at the very end of the play — after the healthcheck gate below. Flush them
# 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.
# Handlers run at the end of the play. Flush them so the healthcheck gate
# tests the container with the current unit.
- name: Apply pending unit changes before verifying
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
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: hops_active
changed_when: false
failed_when: hops_active.stdout != 'active'
# `is-active` says the unit is running, not that both processes inside the
# 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.
# is-active does not prove both processes are healthy. Force a healthcheck.
- name: Wait for the container healthcheck to pass
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: hops_health
@@ -131,8 +117,7 @@
{{ container_name }} is on
{{ hops_networks_now.stdout | default('(container not running)') }}
# Removes the credential from the user's auth file. The image is already local
# and the unit only pulls when it is missing, so this does not break restarts.
# The image is local and the unit only pulls a missing image.
- name: Log out of the private registry
ansible.builtin.command: "podman logout {{ registry }}"
changed_when: false
+9 -20
View File
@@ -1,9 +1,5 @@
# Hops n Grains — rootless Podman Quadlet unit.
# Managed by Ansible (roles/hops). Do not edit by hand.
#
# 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.
# Hops n Grains rootless Quadlet unit. Managed by Ansible.
# Joins {{ network_name }}.network so Caddy resolves {{ container_name }}.
[Unit]
Description=Hops n Grains microbrewery (PocketBase + TanStack Start)
@@ -14,27 +10,21 @@ Wants=network-online.target
ContainerName={{ container_name }}
Image={{ image }}
# The image runs as root: docker-entrypoint.sh starts PocketBase, bootstraps the
# superuser and then starts the site server, and the PocketBase data files it
# 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.
# The image runs as root. keep-id maps that onto {{ podman_user }}, so
# {{ host_directory }} stays owned by the host user.
UserNS=keep-id:uid=0,gid=0
# NODE_ENV, PORT, PB_URL and the PocketBase superuser live here. Read by
# systemd, not by Ansible, so the file has to exist before this unit starts.
# systemd reads the env file. It must exist before the unit starts.
EnvironmentFile={{ host_directory }}/.env
Network={{ network_name }}.network
Volume={{ host_directory }}/pb_data:/app/pb_data
# No PublishPort on purpose: the Docker-era deployment had none either, and
# Caddy reaches both ports over {{ network_name }}_net.
# No PublishPort. Caddy reaches both ports over {{ network_name }}_net.
# podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
# instruction, so the same check is declared here and the play gates on it with
# `podman healthcheck run`. curl is in the runner image.
# podman builds OCI images by default and drops the Dockerfile HEALTHCHECK,
# so declare the check here.
HealthCmd={{ health_cmd }}
HealthInterval=15s
HealthTimeout=3s
@@ -43,8 +33,7 @@ HealthStartPeriod=5s
[Service]
Restart=always
# The entrypoint waits for PocketBase and upserts the superuser before the site
# server answers, so allow generously for a large database.
# The entrypoint waits for PocketBase and the superuser before the site answers.
TimeoutStartSec=300
[Install]
+6 -27
View File
@@ -1,18 +1,6 @@
---
# Register Hops n Grains' network with the shared Caddy unit.
#
# 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 }}.
# Write hops.network and the Caddy drop-in that joins Caddy to it.
# Run from pre_tasks, before the app unit is generated.
- name: Ensure the Quadlet directory exists
ansible.builtin.file:
@@ -29,15 +17,8 @@
group: "{{ podman_user }}"
mode: '0644'
content: |
# {{ network_name }} — logical app network.
# Managed by Ansible. Do not edit by hand.
#
# 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_name }} network unit. Managed by Ansible.
# NetworkName pins the Podman name to {{ network_name }}_net.
[Network]
NetworkName={{ network_name }}_net
Options=isolate=true
@@ -58,10 +39,8 @@
group: "{{ podman_user }}"
mode: '0644'
content: |
# Added by Hops n Grains' playbook. Do not edit by hand.
#
# Quadlet merges this into caddy.container. Deleting this file removes
# the network from Caddy on the next daemon-reload.
# Added by the Hops n Grains playbook. Managed by Ansible.
# Quadlet merges this into caddy.container.
[Container]
Network={{ network_name }}.network
notify: Restart Caddy
+12 -34
View File
@@ -1,67 +1,47 @@
---
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# 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.
# Rootless Podman and Quadlet on emily. The play runs as the connecting user,
# so these paths follow that user's home.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Image
# ---------------------------------------------------------------------------
registry: registry.tanshu.com
registry_username: ta-registry
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
image_name: "{{ registry }}/hops"
tag: latest
# deploy.sh passes -e image_tag=<commit>; `tag` is the fallback when running the
# play by hand. Rollback = -e image_tag=<older-commit>.
# deploy.sh passes image_tag=<commit>. Rollback uses an older commit.
tag: main
image: "{{ image_name }}:{{ image_tag | default(tag) }}"
# ---------------------------------------------------------------------------
# Hops n Grains
# ---------------------------------------------------------------------------
app_name: hops
container_name: "{{ app_name }}"
unit_name: "{{ container_name }}.service"
# The Quadlet file is <network_name>.network; roles/register pins the Podman
# network name to <network_name>_net.
# The register role pins NetworkName to hops_net.
network_name: "{{ app_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# Ports inside the container. Caddy proxies to them over hops_net; nothing is
# published on the host.
# Caddy reaches both ports over hops_net. Nothing is published on the host.
site_port: 3000
pb_port: 8090
# The Dockerfile's HEALTHCHECK is dropped when podman builds an OCI image (its
# default), so the same check is declared on the Quadlet unit and the play gates
# on `podman healthcheck run`. curl is in the runner image.
# podman builds OCI images by default and drops the Dockerfile HEALTHCHECK,
# so the unit declares the check and the play gates on it.
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
apex_host: hopsngrains.com
admin_host: admin.hopsngrains.com
# ---------------------------------------------------------------------------
# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo)
# ---------------------------------------------------------------------------
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
# Appended to the shared Caddyfile by roles/caddy. The sites are served from
# the container name over hops_net; no host ports are involved.
# Appended to the shared Caddyfile by roles/caddy.
snippet_block: |
{{ 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 /_ /_/
handle /_/* {
reverse_proxy {{ container_name }}:{{ pb_port }}
@@ -70,7 +50,7 @@ snippet_block: |
reverse_proxy {{ container_name }}:{{ pb_port }}
}
# TanStack Start frontend application
# TanStack Start frontend
handle {
reverse_proxy {{ container_name }}:{{ site_port }}
}
@@ -82,8 +62,6 @@ snippet_block: |
}
{% endif %}
# ---------------------------------------------------------------------------
# PocketBase initial superuser credentials (bootstrapped on first start)
# ---------------------------------------------------------------------------
# Bootstrapped on first start.
pb_admin_email: admin@hopsngrains.com
pb_admin_password: HopsPassword1234!
+19 -44
View File
@@ -1,72 +1,47 @@
#!/usr/bin/env bash
#
# Hops n Grains deploy — push the current commit, build it with podman, and
# 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
# Deploy HEAD with podman and ansible on emily. Flags: --prune keeps three old
# images, --force skips the guards. The tree must be clean and HEAD pushed.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
BRANCH="${BRANCH:-main}"
git push
FORCE=false
NO_PUSH=false
PRUNE=false
for arg in "$@"; do
case "$arg" in
--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
;;
esac
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 [ -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
fi
git fetch origin --quiet
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$BRANCH")" ]; then
echo "x HEAD != origin/$BRANCH - push or pull first (or --force)" >&2
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "x HEAD != origin/main, push or pull first (or --force)" >&2
exit 1
fi
fi
git pull --ff-only
TAG=$(git rev-parse --short HEAD)
echo "==> deploying commit $TAG"
echo "========================================================"
echo " Building & Uploading Container Image (Podman)"
echo "========================================================"
make build-production COMMIT="$TAG"
echo ""
echo "========================================================"
echo " Executing Ansible Deployment"
echo "========================================================"
# The play configures rootless Podman Quadlet units that belong to the
# 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.
deploy_user="${ANSIBLE_USER:-tanshu}"
cd ansible || exit
# image_tag pins the exact commit; rollback is -e image_tag=<older-commit>.
ansible-playbook -u "$deploy_user" playbook.yml -e "image_tag=$TAG"
if [ "$PRUNE" = true ]; then
PRUNE_FLAG="-e hops_image_prune=true"
else
PRUNE_FLAG=""
fi
cd ansible
# shellcheck disable=SC2086
ansible-playbook playbook.yml -e "image_tag=$TAG" $PRUNE_FLAG