Run the build on Podman and deploy as a rootless Podman Quadlet

- Makefile: podman build + push instead of docker buildx. build-production
  clones the git remote and builds from it; build-check / build-check-local
  build without pushing. Single-architecture (PLATFORM-overridable) rather
  than a multi-arch manifest.
- deploy.sh: connect as the unprivileged container user; no become.
- ansible: target emily, rootless Podman Quadlet behind the fleet's shared
  Caddy unit (caddy/playbook.yml in ansible-playbooks).
  - register role writes greatbear.network (NetworkName=greatbear_net) and
    the caddy.container.d drop-in that puts Caddy on it.
  - greatbear role: podman login/pull, ~/podman-data/greatbear + .env,
    greatbear.container (UserNS=keep-id:uid=0,gid=0, EnvironmentFile,
    Network, Volume, no published ports), systemctl --user start and a
    podman healthcheck gate, with a diagnostics rescue block.
  - caddy role appends the site blocks to the shared Caddyfile and reloads;
    handlers reload vs. recreate Caddy (a new network drop-in re-creates it).
  - drop the network role, the community.docker requirements and the
    ansible/files/ templates.
- .containerignore replaces .dockerignore (Buildah reads it natively).
- README: Podman build, rootless Quadlet deploy. No data migration step —
  there is no PocketBase state to carry over.
This commit is contained in:
tanshu committed 2026-10-16 10:09:12 +05:30
1 parent d9ba227657
commit 9620847108
17 files changed
+523 -183

No files matched your search

-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 The Great Bear's 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
@@ -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 The Great Bear
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
scope: user
state: restarted
daemon_reload: true
listen: Restart The Great Bear
+121 -44
View File
@@ -1,56 +1,133 @@
---
- name: Log in to private Docker registry
community.docker.docker_login:
registry: "{{ registry }}"
username: "{{ username }}"
password: "{{ password }}"
# The Great Bear 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.
# 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
# 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 Great Bear image
community.docker.docker_image:
name: "{{ image_name }}"
source: pull
state: present
force_source: true
ansible.builtin.command: "podman pull {{ image }}"
changed_when: false
- name: Ensure Host Directory exists
- 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.j2"
dest: "{{ host_data_path }}/.env"
mode: "0600"
src: greatbear.env.j2
dest: "{{ host_directory }}/.env"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0600'
notify: Restart The Great Bear
- name: Create and run The Great Bear 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 Great Bear Quadlet unit
ansible.builtin.template:
src: greatbear.container.j2
dest: "{{ quadlet_dir }}/{{ container_name }}.container"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify:
- Reload systemd user units
- Restart The Great Bear
- name: Wait for the application container to become healthy
community.docker.docker_container_info:
name: "{{ container_name }}"
register: app_info
until: app_info.container.State.Health.Status == "healthy"
retries: 30
- name: Start The Great Bear
block:
- name: Start The Great Bear
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 The Great Bear 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: greatbear_diag
changed_when: false
failed_when: false
- name: Show The Great Bear diagnostics
ansible.builtin.debug:
msg: "{{ greatbear_diag.results | map(attribute='stdout') | list }}"
- name: Fail
ansible.builtin.fail:
msg: "The Great Bear 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 Great Bear unit is active
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: greatbear_active
changed_when: false
failed_when: greatbear_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, upserts the superuser and seeds content 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: greatbear_health
until: greatbear_health.rc == 0
retries: 36
delay: 5
changed_when: false
failed_when: greatbear_health.rc != 0
- name: Log out of private Docker registry
community.docker.docker_login:
registry: "{{ registry }}"
state: absent
- 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: greatbear_networks_now
changed_when: false
failed_when: false
- name: Report the network
ansible.builtin.debug:
msg: >-
{{ container_name }} is on
{{ greatbear_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,47 @@
# The Great Bear — rootless Podman Quadlet unit.
# Managed by Ansible (roles/greatbear). 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=The Great Bear 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, seeds content 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, upserts the superuser and seeds content
# before the site server answers, so allow generously for a large database.
TimeoutStartSec=300
[Install]
WantedBy=default.target
@@ -0,0 +1,7 @@
NODE_ENV=production
PORT={{ site_port }}
HOST=0.0.0.0
PB_DATA_DIR=/app/pb_data
PB_URL=http://127.0.0.1:{{ pb_port }}
PB_SUPERUSER_EMAIL={{ pb_admin_email }}
PB_SUPERUSER_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 The Great Bear's 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 greatbear.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 The Great Bear's 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