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:
1 parent
d9ba227657
commit
9620847108
17 files changed
+523
-183
No files matched your search
@@ -1,10 +1,15 @@
|
||||
# Keep the build context small and secret-free
|
||||
# Keep the build context small and secret-free.
|
||||
# Read by Podman/Buildah (`.containerignore`) and, as a fallback, Docker.
|
||||
|
||||
# VCS / editor / local state
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
.containerignore
|
||||
.env
|
||||
.npm-cache
|
||||
.tmp-pb
|
||||
.podman
|
||||
.vscode
|
||||
design
|
||||
*.zip
|
||||
|
||||
@@ -16,9 +21,6 @@ app/dist
|
||||
|
||||
# Ops assets not needed inside the image
|
||||
ansible
|
||||
ansible-sample
|
||||
Dockerfile-sample
|
||||
deploy-sample.sh
|
||||
Makefile
|
||||
README.md
|
||||
scripts/create-collections.sh
|
||||
@@ -1,28 +1,63 @@
|
||||
# The Great Bear — container image builds.
|
||||
#
|
||||
# Built with Podman/Buildah, not Docker. The image is always built from a fresh
|
||||
# clone of the git remote, so it matches a pushed commit; commit and push before
|
||||
# deploying. Registry credentials: `podman login registry.tanshu.com` once per
|
||||
# machine (nothing here prompts).
|
||||
|
||||
IMAGE ?= registry.tanshu.com/tanshu/greatbear
|
||||
REPO ?= git@git.tanshu.com:tanshu/greatbear.git
|
||||
TAG ?= latest
|
||||
|
||||
# Target platform. Defaults to the architecture of the machine running make,
|
||||
# which is what a native `podman build` would use anyway. Only cross-build when
|
||||
# the deploy host differs, e.g.:
|
||||
#
|
||||
# make build-production PLATFORM=linux/arm64
|
||||
#
|
||||
# Cross-building needs QEMU binfmt registered on the controller
|
||||
# (qemu-user-static), the same requirement `docker buildx --platform` had.
|
||||
PLATFORM ?= linux/$(shell uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/' -e 's/armv7l/arm/')
|
||||
|
||||
.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/tanshu/greatbear:latest \
|
||||
$(if $(filter-out latest,$(TAG)),--tag registry.tanshu.com/tanshu/greatbear:$(TAG)) \
|
||||
--push \
|
||||
--pull \
|
||||
git@git.tanshu.com:tanshu/greatbear.git
|
||||
build-production: ## Build the image with podman from the git remote and push it
|
||||
@set -euo pipefail; \
|
||||
ctx="$$(mktemp -d)"; \
|
||||
trap 'rm -rf "$$ctx"' EXIT; \
|
||||
echo "==> cloning $(REPO)"; \
|
||||
git clone --quiet --depth 1 "$(REPO)" "$$ctx/src"; \
|
||||
echo "==> podman build --platform $(PLATFORM) -t $(IMAGE):$(TAG)"; \
|
||||
podman build --pull=always --platform "$(PLATFORM)" \
|
||||
-t "$(IMAGE):$(TAG)" \
|
||||
-f "$$ctx/src/Dockerfile" "$$ctx/src"; \
|
||||
echo "==> podman push $(IMAGE):$(TAG)"; \
|
||||
podman push "$(IMAGE):$(TAG)"; \
|
||||
if [ "$(TAG)" != "latest" ]; then \
|
||||
echo "==> podman push $(IMAGE):latest"; \
|
||||
podman tag "$(IMAGE):$(TAG)" "$(IMAGE):latest"; \
|
||||
podman push "$(IMAGE):latest"; \
|
||||
fi
|
||||
|
||||
.PHONY: build-check
|
||||
build-check: ## Multi-arch build without push (compile check)
|
||||
@docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag greatbear:test \
|
||||
--pull \
|
||||
--progress=plain \
|
||||
git@git.tanshu.com:tanshu/greatbear.git
|
||||
build-check: ## Build from the git remote without pushing (compile check)
|
||||
@set -euo pipefail; \
|
||||
ctx="$$(mktemp -d)"; \
|
||||
trap 'rm -rf "$$ctx"' EXIT; \
|
||||
echo "==> cloning $(REPO)"; \
|
||||
git clone --quiet --depth 1 "$(REPO)" "$$ctx/src"; \
|
||||
echo "==> podman build --platform $(PLATFORM) greatbear:test"; \
|
||||
podman build --pull=always --platform "$(PLATFORM)" \
|
||||
-t localhost/greatbear:test \
|
||||
-f "$$ctx/src/Dockerfile" "$$ctx/src"
|
||||
|
||||
.PHONY: build-check-local
|
||||
build-check-local: ## Build from the committed local tree (git archive) and load into docker
|
||||
@git archive --format=tar HEAD | docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--tag greatbear:test \
|
||||
--pull \
|
||||
--progress=plain \
|
||||
--load \
|
||||
-
|
||||
build-check-local: ## Build the committed local tree (git archive) without pushing
|
||||
@set -euo pipefail; \
|
||||
ctx="$$(mktemp -d)"; \
|
||||
trap 'rm -rf "$$ctx"' EXIT; \
|
||||
mkdir -p "$$ctx/src"; \
|
||||
git archive --format=tar HEAD | tar -x -C "$$ctx/src"; \
|
||||
echo "==> podman build --platform $(PLATFORM) greatbear:test (local HEAD)"; \
|
||||
podman build --pull=always --platform "$(PLATFORM)" \
|
||||
-t localhost/greatbear:test \
|
||||
-f "$$ctx/src/Dockerfile" "$$ctx/src"
|
||||
@@ -7,9 +7,9 @@ Stitch "Industrial Hospitality" design.
|
||||
| --------- | ----------------------------------------------------------- |
|
||||
| Frontend | [TanStack Start](https://tanstack.com/start) (React 19, Vite 8, Tailwind CSS 4, SSR) |
|
||||
| Backend | [PocketBase](https://pocketbase.io) v0.40 — CMS content, form intake, admin UI |
|
||||
| Runtime | Single Docker image running both services |
|
||||
| Proxy | Caddy (automatic HTTPS) on the target host |
|
||||
| Deploys | `deploy.sh [tag]` (Makefile buildx push) → Ansible playbook |
|
||||
| Runtime | Single image running both services, as a rootless Podman Quadlet unit |
|
||||
| Proxy | Caddy (automatic HTTPS), also a rootless Podman Quadlet unit |
|
||||
| Deploys | `deploy.sh [tag]` (Podman build + push) → Ansible playbook |
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -35,11 +35,12 @@ pb/migrations/ PocketBase schema (snapshot migration, auto-applied)
|
||||
scripts/
|
||||
seed.mjs Idempotent content seeder (create-if-missing by uid)
|
||||
create-collections.sh Dev helper used to build the schema migration
|
||||
ansible/ Deployment playbook (roles: network, greatbear, caddy)
|
||||
Makefile Multi-arch buildx targets (build-production / build-check)
|
||||
ansible/ Deployment playbook (roles: register, greatbear, caddy)
|
||||
Makefile Podman build targets (build-production / build-check)
|
||||
Dockerfile Multi-stage image build (deps → builder → pb-downloader → runner)
|
||||
docker-entrypoint.sh Container supervisor: PocketBase + site server
|
||||
deploy.sh Build & push image, then run the Ansible deploy
|
||||
.containerignore Build-context excludes (Podman/Buildah; .dockerignore fallback)
|
||||
```
|
||||
|
||||
## Local development
|
||||
@@ -79,16 +80,18 @@ cd app && npm run dev
|
||||
**production image** locally instead:
|
||||
|
||||
```bash
|
||||
docker build -t greatbear:dev .
|
||||
docker run --rm -p 3000:3000 -p 8090:8090 \
|
||||
podman build -t localhost/greatbear:dev .
|
||||
podman run --rm -p 3000:3000 -p 8090:8090 \
|
||||
-e PB_SUPERUSER_EMAIL=dev@greatbear.in -e PB_SUPERUSER_PASSWORD=devpass12345 \
|
||||
-v greatbear_dev_data:/app/pb_data greatbear:dev
|
||||
-v greatbear_dev_data:/app/pb_data localhost/greatbear:dev
|
||||
```
|
||||
|
||||
## Deploying
|
||||
|
||||
The image is always built from the **committed state of the git remote** — commit and push
|
||||
before deploying. Registry credentials: `docker login registry.tanshu.com` (once per machine).
|
||||
The image is built with **Podman/Buildah** (not Docker) and always from the
|
||||
**committed state of the git remote** — commit and push before deploying. The
|
||||
Makefile never prompts, so log in to the registry once per machine:
|
||||
`podman login registry.tanshu.com`.
|
||||
|
||||
```bash
|
||||
./deploy.sh # build + push :latest, then deploy that tag via Ansible
|
||||
@@ -98,43 +101,66 @@ before deploying. Registry credentials: `docker login registry.tanshu.com` (once
|
||||
Or run the pieces yourself:
|
||||
|
||||
```bash
|
||||
make build-production TAG=v1.2.3 # multi-arch (amd64+arm64) buildx build & push
|
||||
make build-check # multi-arch compile check (no push)
|
||||
make build-check-local # build the committed local tree (git archive) and load it
|
||||
make build-production TAG=v1.2.3 # podman build + push from the git remote
|
||||
make build-check # build from the git remote, no push
|
||||
make build-check-local # build the committed local tree (git archive), no push
|
||||
|
||||
cd ansible && ansible-playbook playbook.yml -e "tag=v1.2.3"
|
||||
cd ansible && ansible-playbook -u tanshu playbook.yml -e "tag=v1.2.3"
|
||||
```
|
||||
|
||||
Images are built for the controller's architecture by default. Only cross-build
|
||||
when the deploy host differs, and register QEMU binfmt first
|
||||
(`qemu-user-static`), the same requirement `docker buildx --platform` had:
|
||||
|
||||
```bash
|
||||
make build-production PLATFORM=linux/arm64
|
||||
```
|
||||
|
||||
### Ansible deployment
|
||||
|
||||
Requirements on the controller: `ansible` + `community.docker` collection (see
|
||||
`ansible/requirements.yml`). Requirements on the host: Docker with a Caddy **container** named
|
||||
`caddy` (config at `/var/lib/caddy/conf/Caddyfile`).
|
||||
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-galaxy install -r requirements.yml
|
||||
ansible-playbook playbook.yml # targets inventory host `monoco` — edit playbook.yml if needed
|
||||
ansible-playbook -u tanshu playbook.yml -e "tag=v1.2.3"
|
||||
```
|
||||
|
||||
All deployment variables live in `ansible/vars/default.yml` (registry, tag, domains,
|
||||
PocketBase admin credentials). The playbook:
|
||||
All deployment variables live in `ansible/vars/default.yml` (registry, tag,
|
||||
domains, PocketBase admin credentials). The play:
|
||||
|
||||
1. Ensures the `greatbear_net` Docker network exists with the Caddy container attached.
|
||||
2. Pulls `registry.tanshu.com/tanshu/greatbear:{tag}`, uploads `/var/lib/greatbear/.env`,
|
||||
and (re)creates the `greatbear` container on that network with
|
||||
`/var/lib/greatbear/pb_data` bind-mounted to `/app/pb_data`, then waits for its healthcheck.
|
||||
3. Inserts a managed snippet into the shared Caddyfile and reloads Caddy via
|
||||
`docker exec`:
|
||||
1. Writes `~/.config/containers/systemd/greatbear.network`
|
||||
(`NetworkName=greatbear_net`) and a drop-in that attaches the shared Caddy
|
||||
unit to it, so Caddy can resolve `greatbear` by container name.
|
||||
2. Logs in to the private registry, pulls
|
||||
`registry.tanshu.com/tanshu/greatbear:{tag}`, writes
|
||||
`~/podman-data/greatbear/.env`, installs
|
||||
`~/.config/containers/systemd/greatbear.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.greatbear.in` (+ apex `greatbear.in`) — frontend on port 3000, PocketBase
|
||||
API/admin proxied under `/api/*` and `/_/*`
|
||||
- `www.greatbear.in` (+ apex `greatbear.in`) — frontend on port 3000,
|
||||
PocketBase API/admin proxied under `/api/*` and `/_/*`
|
||||
- `admin.greatbear.in` — redirects to the PocketBase admin UI
|
||||
|
||||
No host ports are published; Caddy reaches the containers over the Docker network.
|
||||
Nothing is published on the host; Caddy reaches the container over
|
||||
`greatbear_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/greatbear` 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 the server; Caddy obtains TLS certificates
|
||||
automatically on first request.
|
||||
DNS records for all three hosts must point at the server; Caddy obtains TLS
|
||||
certificates automatically on first request.
|
||||
|
||||
## PocketBase admin
|
||||
|
||||
|
||||
@@ -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
@@ -1,11 +1,36 @@
|
||||
---
|
||||
- name: Deploy The Great Bear Microbrewery
|
||||
hosts: monoco
|
||||
become: true
|
||||
# The Great Bear — 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 The Great Bear 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
|
||||
# greatbear.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 `greatbear` resolvable to Caddy.
|
||||
- name: Register The Great Bear's 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.
|
||||
- greatbear
|
||||
# Appends this site's blocks to the shared Caddyfile and reloads Caddy.
|
||||
- caddy
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
collections:
|
||||
- name: community.docker
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
caddy_container: caddy
|
||||
caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +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:8090
|
||||
PB_URL=http://127.0.0.1:{{ pb_port }}
|
||||
PB_SUPERUSER_EMAIL={{ pb_admin_email }}
|
||||
PB_SUPERUSER_PASSWORD={{ pb_admin_password }}
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Ensure Docker network exists
|
||||
community.docker.docker_network:
|
||||
name: "{{ docker_network }}"
|
||||
state: present
|
||||
connected:
|
||||
- "{{ caddy_container }}"
|
||||
appends: true
|
||||
@@ -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
|
||||
+67
-12
@@ -1,27 +1,82 @@
|
||||
---
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
image_name: "{{ registry }}/tanshu/greatbear"
|
||||
tag: latest
|
||||
image: "{{ image_name }}:{{ tag }}"
|
||||
|
||||
image_name: "{{ registry }}/tanshu/greatbear:{{ tag }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The Great Bear
|
||||
# ---------------------------------------------------------------------------
|
||||
app_name: greatbear
|
||||
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 hosts for frontend & PocketBase routing
|
||||
# The Quadlet file is <network_name>.network; roles/register pins the Podman
|
||||
# network name to <network_name>_net, the name the Docker-era deployment used.
|
||||
network_name: "{{ app_name }}"
|
||||
|
||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||
|
||||
# Ports inside the container. Caddy proxies to them over greatbear_net; nothing
|
||||
# is published on the host.
|
||||
site_port: 3000
|
||||
pb_port: 8090
|
||||
|
||||
# Public hosts for frontend & PocketBase routing.
|
||||
host: www.greatbear.in
|
||||
apex_host: greatbear.in
|
||||
admin_host: admin.greatbear.in
|
||||
|
||||
# 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 greatbear_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@greatbear.in
|
||||
pb_admin_password: GreatBear!Admin2024
|
||||
@@ -5,7 +5,7 @@ parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" || exit ; pwd -P )
|
||||
cd "$parent_path" || exit
|
||||
|
||||
echo "========================================================"
|
||||
echo " Building & Uploading Docker Image"
|
||||
echo " Building & Uploading Container Image (Podman)"
|
||||
echo "========================================================"
|
||||
current_version="${1:-latest}"
|
||||
make build-production TAG="$current_version"
|
||||
@@ -15,5 +15,9 @@ 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 "$parent_path/ansible" || exit
|
||||
ansible-playbook playbook.yml -e "tag=$current_version"
|
||||
ansible-playbook -u "$deploy_user" playbook.yml -e "tag=$current_version"
|
||||
Reference in new issue
Block a user