Build with Podman and deploy as a rootless Quadlet on emily

- Makefile: podman build/push from the committed tree (git archive HEAD),
  tagged :<commit> + :latest; PLATFORM defaults to linux/arm64 for emily.
- deploy.sh: push the commit first, refuse a dirty tracked tree or
  HEAD != origin/main, then build and deploy that exact commit via
  -e image_tag (rollback = an older commit).
- ansible: target emily with no become. The register role writes hops.network
  (NetworkName=hops_net) plus the caddy.container.d drop-in; the hops role
  installs ~/podman-data/hops and hops.container under systemctl --user with a
  podman healthcheck gate; the caddy role appends the site blocks to the shared
  Caddyfile and reloads.
- Drop the Docker-era network role, community.docker requirements,
  ansible/files templates and docker-compose.yml.
- .containerignore replaces .dockerignore.
- README: Podman build, rootless Quadlet deploy on emily.
This commit is contained in:
tanshu committed 2026-10-16 10:35:57 +05:30
1 parent 486ed14ee9
commit 843c8ba78a
20 files changed
+618 -235

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# 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
-40
View File
@@ -1,40 +0,0 @@
# Version control & IDEs
.git
.gitignore
.dockerignore
.vscode
.idea
.agents
.gemini
# Build artifacts & compiled outputs
.output
frontend/.output
frontend/.tanstack
frontend/dist
dist
# Dependencies
node_modules
frontend/node_modules
# PocketBase local data & local binaries
backend/pb_data
backend/pocketbase
backend/pocketbase.exe
*.db
*.db-shm
*.db-wal
# Logs & temporary files
*.log
npm-debug.log*
scratch
page.html
.DS_Store
Thumbs.db
# Documentation
README.md
walkthrough.md
implementation_plan.md
-1
View File
@@ -23,7 +23,6 @@ backend/pocketbase.exe
.env.local
.env.*.local
!.env.example
!ansible/files/.env
# IDEs & System files
.vscode/
+38 -32
View File
@@ -1,36 +1,42 @@
# 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
IMAGE ?= registry.tanshu.com/hops
.PHONY: build-production
build-production: ## Build the production docker image and push to private registry
@docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag registry.tanshu.com/hops:latest \
$(if $(filter-out latest,$(TAG)),--tag registry.tanshu.com/hops:$(TAG)) \
--push \
--pull \
git@git.tanshu.com:tanshu/hops.git
build-production: ## Build HEAD with podman, tag :$(COMMIT) + :latest, and push
@echo "==> podman build --platform $(PLATFORM) → $(IMAGE):$(COMMIT) + :latest"
@git archive --format=tar HEAD | podman build \
--platform $(PLATFORM) \
--tag $(IMAGE):$(COMMIT) \
--tag $(IMAGE):latest \
--pull=always \
- || { \
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"; \
exit 1; \
}
podman push $(IMAGE):$(COMMIT)
podman push $(IMAGE):latest
.PHONY: build-check
build-check: ## Multi-arch build without push (compile check)
@docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag hops:test \
--pull \
--progress=plain \
git@git.tanshu.com:tanshu/hops.git
.PHONY: build-check-local
build-check-local: ## Multi-arch build without push (compile check)
@git archive --format=tar HEAD | docker buildx build \
--platform linux/amd64 \
--tag hops:test \
--pull \
--progress=plain \
--load \
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 \
-
# .PHONY: deploy
# deploy: ## Build, upload to registry, and trigger Ansible deployment
# @./deploy.sh --deploy $(if $(TAG),$(TAG))
# .PHONY: ansible-deploy
# ansible-deploy: ## Run Ansible playbook directly
# @cd ansible && ansible-playbook -i hosts playbook.yml $(if $(TAG),-e "tag=$(TAG)")
+79 -21
View File
@@ -34,11 +34,13 @@ hops/
│ │ │ └── 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
├── .dockerignore # Container build ignore rules
├── .containerignore # Build-context excludes (Podman/Buildah)
├── .gitignore # Git repository ignore rules
├── docker-compose.yml # Local & server orchestration with persistent storage
├── 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
```
@@ -75,34 +77,90 @@ npm run dev:frontend
---
## Docker Deployment
## Deploying
The application is containerized using a multi-stage Docker build that bundles both the TanStack Start Nitro SSR server and PocketBase into a single, self-contained container.
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`.
### Option A: Using Docker Compose (Recommended)
```bash
docker compose up --build -d
./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
```
- Web Application: `http://localhost:3000`
- PocketBase Admin: `http://localhost:8090/_/`
- Data Persistence: Automatically stored in the `pb_data` named Docker volume.
### Option B: Building & Running Directly with Docker
Or run the pieces yourself:
```bash
# Build the Docker image
docker build -t hops-n-grains:latest .
make build-production # podman build HEAD, push :<commit> + :latest
make build-check # compile check from HEAD, no push
# Run container with persistent volume
docker run -d \
--name hops-app \
-p 3000:3000 \
-p 8090:8090 \
-v hops_pb_data:/app/pb_data \
-e PB_ADMIN_EMAIL=admin@hopsngrains.com \
-e PB_ADMIN_PASSWORD=HopsPassword1234! \
hops-n-grains:latest
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:
```bash
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.
```bash
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/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:
- `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
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
-22
View File
@@ -1,22 +0,0 @@
{{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} {
# PocketBase Admin UI and REST API
redir /_ /_/
handle /_/* {
reverse_proxy {{ container_name }}:8090
}
handle /api/* {
reverse_proxy {{ container_name }}:8090
}
# TanStack Start Frontend Application
handle {
reverse_proxy {{ container_name }}:3000
}
}
{% if admin_host is defined and admin_host %}
{{ admin_host }} {
redir / /_/
reverse_proxy {{ container_name }}:8090
}
{% endif %}
+29 -4
View File
@@ -1,11 +1,36 @@
---
- name: Deploy Hops n Grains Microbrewery
hosts: monoco
become: true
# 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
- name: Deploy Hops n Grains as a rootless Podman Quadlet
hosts: emily
gather_facts: true
vars_files:
- 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
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/register/tasks/main.yaml"
roles:
- network
# The app: data directory, .env, the Quadlet unit, start + healthcheck.
- hops
# Appends this site's blocks to the shared Caddyfile and reloads Caddy.
- caddy
-3
View File
@@ -1,3 +0,0 @@
---
collections:
- name: community.docker
-3
View File
@@ -1,3 +0,0 @@
---
caddy_container: caddy
caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile
+28 -2
View File
@@ -1,5 +1,31 @@
---
# 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.
- name: Reload Caddy configuration
ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload"
listen: "Reload Caddy"
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.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
scope: user
state: restarted
daemon_reload: true
listen: Restart Caddy
+17 -21
View File
@@ -1,26 +1,22 @@
---
- name: Read snippet from template file
ansible.builtin.set_fact:
caddy_snippet_block: "{{ lookup('template', 'files/Caddyfile.j2') }}"
# 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.
- name: Read current Caddyfile
ansible.builtin.slurp:
path: "{{ caddy_caddyfile_path }}"
register: caddy_caddyfile_raw
- name: Decode Caddyfile content
ansible.builtin.set_fact:
caddy_caddyfile_content: "{{ caddy_caddyfile_raw['content'] | b64decode }}"
- name: Check if snippet already exists
ansible.builtin.set_fact:
caddy_snippet_present: "{{ caddy_snippet_block in caddy_caddyfile_content }}"
- name: Add or update snippet in Caddyfile
- name: "Add the {{ host }} site blocks to the Caddyfile"
ansible.builtin.blockinfile:
path: "{{ caddy_caddyfile_path }}"
path: "{{ caddyfile_path }}"
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
block: "{{ caddy_snippet_block }}"
block: "{{ snippet_block }}"
create: true
mode: "0644"
notify: Reload Caddy configuration
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify: Reload Caddy
+16
View File
@@ -0,0 +1,16 @@
---
# All handlers run as the connection user ({{ podman_user }}), so
# `systemctl --user` works without setting XDG_RUNTIME_DIR.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
changed_when: true
listen: Reload systemd user units
- name: Restart Hops n Grains
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
scope: user
state: restarted
daemon_reload: true
listen: Restart Hops n Grains
+125 -35
View File
@@ -1,43 +1,133 @@
---
- name: Log in to private Docker registry
community.docker.docker_login:
registry: "{{ registry }}"
username: "{{ username }}"
password: "{{ password }}"
# 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.
- name: Pull Hops n Grains image
community.docker.docker_image:
name: "{{ image_name }}"
source: pull
state: present
force_source: true
# 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 }}"
stdin: "{{ registry_password }}"
no_log: true
changed_when: false
when:
- registry_username | length > 0
- registry_password | length > 0
- name: Ensure Host Directory exists
# 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.
- name: Pull the Hops n Grains image
ansible.builtin.command: "podman pull {{ image }}"
changed_when: false
- name: Ensure the PocketBase data directory exists
ansible.builtin.file:
path: "{{ host_data_path }}"
path: "{{ host_directory }}/pb_data"
state: directory
mode: "0755"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: Ensure PocketBase data directory exists
ansible.builtin.file:
path: "{{ host_data_path }}/pb_data"
state: directory
mode: "0755"
- name: Upload the .env file
# 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.
- name: Write the environment file
ansible.builtin.template:
src: "files/.env"
dest: "{{ host_data_path }}/.env"
mode: "0600"
src: hops.env.j2
dest: "{{ host_directory }}/.env"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0600'
notify: Restart Hops n Grains
- name: Create and run Hops n Grains container
community.docker.docker_container:
name: "{{ container_name }}"
image: "{{ image_name }}"
state: started
restart_policy: "unless-stopped"
env_file: "{{ host_data_path }}/.env"
volumes:
- "{{ host_data_path }}/pb_data:/app/pb_data"
networks:
- name: "{{ docker_network }}"
- name: Install the Hops n Grains Quadlet unit
ansible.builtin.template:
src: hops.container.j2
dest: "{{ quadlet_dir }}/{{ container_name }}.container"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify:
- Reload systemd user units
- Restart Hops n Grains
- name: Start Hops n Grains
block:
- name: Start Hops n Grains
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
scope: user
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
ansible.builtin.command: "{{ item }}"
loop:
- "systemctl --user status {{ unit_name }} --no-pager"
- "journalctl --user -u {{ unit_name }} --no-pager -n 60"
- "podman logs --tail 60 {{ container_name }}"
register: hops_diag
changed_when: false
failed_when: false
- name: Show Hops n Grains 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."
# 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.
- name: Wait for the container healthcheck to pass
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: hops_health
until: hops_health.rc == 0
retries: 36
delay: 5
changed_when: false
failed_when: hops_health.rc != 0
- name: Show the networks the container is attached to
ansible.builtin.command: >-
podman inspect {{ container_name }}
--format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}'
register: hops_networks_now
changed_when: false
failed_when: false
- name: Report the network
ansible.builtin.debug:
msg: >-
{{ 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.
- name: Log out of the private registry
ansible.builtin.command: "podman logout {{ registry }}"
changed_when: false
failed_when: false
when:
- registry_username | length > 0
- registry_password | length > 0
@@ -0,0 +1,46 @@
# 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.
[Unit]
Description=Hops n Grains microbrewery (PocketBase + TanStack Start)
After=network-online.target
Wants=network-online.target
[Container]
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.
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.
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.
# The image's own HEALTHCHECK (PocketBase /api/health on {{ pb_port }} and the
# site on {{ site_port }}) is used as is; the playbook gates on
# `podman healthcheck run`.
[Service]
Restart=always
# The entrypoint waits for PocketBase and upserts the superuser before the site
# server answers, so allow generously for a large database.
TimeoutStartSec=300
[Install]
WantedBy=default.target
@@ -1,6 +1,7 @@
NODE_ENV=production
PORT=3000
PORT={{ site_port }}
HOST=0.0.0.0
PB_DATA_DIR=/app/pb_data
PB_URL=http://127.0.0.1:{{ pb_port }}
PB_ADMIN_EMAIL={{ pb_admin_email }}
PB_ADMIN_PASSWORD={{ pb_admin_password }}
-8
View File
@@ -1,8 +0,0 @@
---
- name: Ensure Docker network exists
community.docker.docker_network:
name: "{{ docker_network }}"
state: present
connected:
- "{{ caddy_container }}"
appends: true
+67
View File
@@ -0,0 +1,67 @@
---
# 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 }}.
- name: Ensure the Quadlet directory exists
ansible.builtin.file:
path: "{{ quadlet_dir }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: "Create the {{ network_name }} network unit"
ansible.builtin.copy:
dest: "{{ quadlet_dir }}/{{ network_name }}.network"
owner: "{{ podman_user }}"
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]
NetworkName={{ network_name }}_net
Options=isolate=true
notify: Restart Caddy
- name: Ensure the Caddy Quadlet drop-in directory exists
ansible.builtin.file:
path: "{{ quadlet_dir }}/{{ caddy_name }}.container.d"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: "Register {{ network_name }} with Caddy"
ansible.builtin.copy:
dest: "{{ quadlet_dir }}/{{ caddy_name }}.container.d/50-{{ network_name }}.conf"
owner: "{{ podman_user }}"
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.
[Container]
Network={{ network_name }}.network
notify: Restart Caddy
+69 -13
View File
@@ -1,28 +1,84 @@
---
# ---------------------------------------------------------------------------
# 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.
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
username: ta-registry
password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
registry_username: ta-registry
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
title: hops
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>.
image: "{{ image_name }}:{{ image_tag | default(tag) }}"
image_name: "{{ registry }}/hops:{{ tag }}"
# ---------------------------------------------------------------------------
# Hops n Grains
# ---------------------------------------------------------------------------
app_name: hops
host_directory: "{{ app_name }}"
host_data_path: "/var/lib/{{ host_directory }}"
container_name: "{{ app_name }}"
docker_network: "{{ app_name }}_net"
unit_name: "{{ container_name }}.service"
# Public host for frontend & PocketBase routing
# The Quadlet file is <network_name>.network; roles/register pins the Podman
# network name to <network_name>_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.
site_port: 3000
pb_port: 8090
# Public hosts for frontend & PocketBase routing.
host: www.hopsngrains.com
apex_host: hopsngrains.com
admin_host: admin.hopsngrains.com
# Caddy configuration
caddy_container: caddy
caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile
# ---------------------------------------------------------------------------
# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo)
# ---------------------------------------------------------------------------
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
# PocketBase Initial Superuser Credentials
# Appended to the shared Caddyfile by roles/caddy. The sites are served from
# the container name over hops_net; no host ports are involved.
snippet_block: |
{{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} {
# PocketBase Admin UI and REST API
redir /_ /_/
handle /_/* {
reverse_proxy {{ container_name }}:{{ pb_port }}
}
handle /api/* {
reverse_proxy {{ container_name }}:{{ pb_port }}
}
# TanStack Start frontend application
handle {
reverse_proxy {{ container_name }}:{{ site_port }}
}
}
{% if admin_host is defined and admin_host %}
{{ admin_host }} {
redir / /_/
reverse_proxy {{ container_name }}:{{ pb_port }}
}
{% endif %}
# ---------------------------------------------------------------------------
# PocketBase initial superuser credentials (bootstrapped on first start)
# ---------------------------------------------------------------------------
pb_admin_email: admin@hopsngrains.com
pb_admin_password: HopsPassword1234!
+61 -8
View File
@@ -1,19 +1,72 @@
#!/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
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" || exit ; pwd -P )
cd "$parent_path" || exit
BRANCH="${BRANCH:-main}"
FORCE=false
NO_PUSH=false
for arg in "$@"; do
case "$arg" in
--force) FORCE=true ;;
--no-push) NO_PUSH=true ;;
*)
echo "Usage: $0 [--force] [--no-push]" >&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
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
exit 1
fi
fi
TAG=$(git rev-parse --short HEAD)
echo "==> deploying commit $TAG"
echo "========================================================"
echo " Building & Uploading Docker Image"
echo " Building & Uploading Container Image (Podman)"
echo "========================================================"
current_version="${1:-latest}"
make build-production TAG="$current_version"
make build-production COMMIT="$TAG"
echo ""
echo "========================================================"
echo " Executing Ansible Deployment"
echo "========================================================"
cd "$parent_path/ansible" || exit
ansible-playbook playbook.yml -e "tag=$current_version"
# 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"
-21
View File
@@ -1,21 +0,0 @@
services:
hops:
build:
context: .
dockerfile: Dockerfile
image: hops-n-grains:latest
container_name: hops-app
restart: unless-stopped
ports:
- "3000:3000"
- "8090:8090"
environment:
- PORT=3000
- PB_ADMIN_EMAIL=admin@hopsngrains.com
- PB_ADMIN_PASSWORD=HopsPassword1234!
volumes:
- pb_data:/app/pb_data
volumes:
pb_data:
driver: local