Run Gitea as a rootless Podman Quadlet, with git over SSH on the host's sshd

Replaces the docker_container deployment on monoco with a Quadlet systemd user
unit on emily, and moves state from /var/lib/gitea to ~/podman-data/gitea. Gitea
is SQLite -- the live app.ini on monoco says DB_TYPE = sqlite3 with
PATH = /data/gitea/gitea.db -- so there is no postgres role and the database
travels inside the data tree.

git-over-SSH is the part that could not simply be moved. Port 22 stays the host's
sshd, so no client changes its remote, but the second hop does: the Docker-era
design sshed into an sshd *inside* the container, and under keep-id that sshd runs
as a subuid which cannot read the authorized_keys Gitea writes as tanshu. It is
replaced by a `podman exec`, reached through a shim sshd runs as a new `git` login
user, a one-command sudoers rule, and an AuthorizedKeysCommand that hands sshd the
key file without the ownership check StrictModes would otherwise apply.

Two things stopped it starting, and both only showed up when it was run:

keep-id runs the container *process* as the uid it maps, not just the file
ownership, so the whole container came up as `git` while the image's start-up is
written to begin as root. /usr/bin/entrypoint rewrites /etc/passwd,
/etc/s6/gitea/setup prepares /data before the run script `su-exec git`s the
server, and the image's own CMD is s6-svscan, which has to create
.s6-svscan/lock inside root-owned /etc/s6. The container died on the last two --
"sed: can't create temp file '/etc/passwdXXXXXX': Permission denied" and
"s6-svscan: fatal: unable to open .s6-svscan/lock: Permission denied" -- and
Restart=always turned that into a crash loop, which `is-active` reports as
`activating` rather than as a failure. User=0 restores root for the start-up and
leaves the mapping alone: the server still runs as git through su-exec, which is
what satisfies mustNotRunAsRoot. (nextcloud.container carries the same pair for
the same reason.)

The `ssh/` directory under /data is only ever a mount point -- the in-image
sshd's host keys are mounted over it from ~/podman-data/gitea-sshd -- so it is
hidden inside the container and nothing ever chowns it. When Podman has to create
it, it lands in the data tree owned by the subuid, and the role's own "nothing
here belongs to another user" check fails on it, correctly. Creating it in the
role first is what keeps the tree wholly tanshu's.

Start, is-active and the health check are now one block whose rescue dumps
status, journal and podman logs, because they fail the same way: `systemctl
start` reports success as soon as systemd holds the unit, so a container that
dies a second later surfaces only as `activating` -- which is exactly how the two
failures above were reported, with the reason in the journal and nowhere the play
looked.

Verified on emily: the play runs to ok=27/changed=7/failed=0, the health check
and the stray-owner assertion both pass, and the ssh second hop still returns the
container's Gitea 28.1.0 through the wrapper. The role's comments and the readme
were corrected where they explained the old failures as EPERM from the root
phases: container root holds the usual capabilities inside its user namespace, so
those phases succeed and leave subuid-owned files behind, which is the real reason
the tree is created up front. The readme's list of "expected" chmod EPERM lines is
gone, because the log is clean. The data directory was emptied of the fresh
app.ini afterwards and the unit left stopped, ready for the copy from monoco.
This commit is contained in:
tanshu committed 2026-10-16 16:44:14 +05:30
1 parent 3d4ccdd6d1
commit fc70acb66b
14 files changed
+1260 -106

No files matched your search

+28 -2
View File
@@ -1,5 +1,31 @@
---
# Handlers for Caddy role
# Caddy is its own playbook (caddy/playbook.yml) 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 this role 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"
ansible.builtin.command: >-
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
changed_when: true
listen: "Reload Caddy"
# Notified by caddy/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. See caddy/readme.md,
# "Trade-off worth knowing".
#
# 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
+16 -16
View File
@@ -1,22 +1,22 @@
---
- name: Read current Caddyfile
ansible.builtin.slurp:
path: "{{ caddyfile_path }}"
register: caddyfile_raw
# Append this site's block 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 the Caddy playbook has
# not run.
- name: Decode Caddyfile content
set_fact:
caddyfile_content: "{{ caddyfile_raw['content'] | b64decode }}"
- name: Check if snippet already exists
set_fact:
snippet_present: "{{ snippet_block in caddyfile_content }}"
- name: Add snippet if missing
- name: "Add the {{ host }} site block to the Caddyfile"
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
marker: "# {mark} Ansible managed Caddy snippet for {{ http_host }}"
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
block: "{{ snippet_block }}"
create: yes
when: not snippet_present
notify: "Reload Caddy"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify: Reload Caddy