Files
playbooks/gitea/vars/default.yml
T
tanshu fc70acb66b 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.
2026-10-16 16:44:14 +05:30

119 lines
5.5 KiB
YAML

---
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
# file wholesale, because it also sets `host_directory` — to the Caddy data
# directory — and every playbook defines that for itself. Keep these three in
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
# belongs to the podman tasks, which this play does not run: Gitea publishes
# nothing host-side.)
#
# podman_user is derived from the connection: this play carries no become at
# play level, so the Quadlet units belong to whoever Ansible connects as.
# Requires gather_facts (set in playbook.yml).
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Gitea
# ---------------------------------------------------------------------------
host: git.tanshu.com
container_name: gitea
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
image: docker.io/gitea/gitea:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: gitea.network, gitea_net).
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# Port inside the container that Caddy proxies to — app.ini's HTTP_PORT. Not
# published on the host: Caddy reaches it by container name.
container_port: 3000
# The uid/gid of the `git` account the image creates for itself
# (git:x:1003:1003, verified in the image), and therefore the uid Gitea runs as.
# The number is load-bearing three times over:
#
# * UserNS in the Quadlet unit maps it onto podman_user, which is what keeps
# every file Gitea writes owned by podman_user on the host — plain tar and
# rsync backups, no subuids;
# * USER_UID/USER_GID force the entrypoint to keep it, so the file layout and
# the mapping cannot drift apart;
# * roles/ssh execs into the container as exactly this uid.
#
# Change one and the other two have to change with it.
gitea_uid: 1003
gitea_gid: 1003
# Where the image keeps its data *inside* the container, and therefore the
# GITEA_CUSTOM the image's own environment already sets.
gitea_custom: /data/gitea
# Gitea's own unauthenticated health endpoint. The image ships no HEALTHCHECK,
# so the unit defines one from this and the play gates on it.
health_cmd: "curl -fsS -o /dev/null http://127.0.0.1:{{ container_port }}/api/healthz"
# Only log timestamps are affected: Gitea stores each user's own timezone. The
# Docker-era container bind-mounted the host's /etc/localtime and /etc/timezone
# instead; TZ is the same intent without depending on either file existing.
timezone: Asia/Kolkata
# A scratch directory for the sshd *inside* the image, which this deployment
# does not use but which still has to write host keys somewhere. It is kept
# outside the data tree on purpose: the process that runs it is the container's
# root, which under keep-id is a subuid, and a subuid-owned file inside
# {{ host_directory }} would break both a plain tar backup and the play's
# "nothing in here belongs to another user" check. See readme.md.
container_sshd_hostkeys: "{{ podman_data_dir }}/{{ container_name }}-sshd"
# ---------------------------------------------------------------------------
# The git-over-SSH entry point on the host
# ---------------------------------------------------------------------------
# Clients keep using git@git.tanshu.com:user/repo.git on port 22, served by the
# *host's* sshd, exactly as they did on monoco. No sshd runs inside the
# container: under keep-id the container's root is a subuid that cannot read the
# authorized_keys Gitea writes as podman_user, so the second hop is a `podman
# exec` instead of an ssh. See readme.md, "How git over SSH works now".
#
# `git_home` only has to exist — sshd runs the forced command from it — and its
# uid is deliberately left to the system: nothing in this design pairs the host
# account with the container's uid. See roles/ssh/tasks/main.yaml.
git_user: git
git_home: /home/git
# The command Gitea writes into every authorized_keys line it maintains. This is
# a contract, not a knob: it is already baked into the lines sitting in the file
# copied from monoco, and renaming it locks every user out of git-over-SSH until
# Gitea rewrites them. See roles/ssh/templates/gitea.j2.
shim_path: /usr/local/bin/gitea
# Runs as podman_user, via the sudoers drop-in, and does the podman exec.
podman_exec_wrapper: /usr/local/bin/gitea-container-exec
# What sshd calls to obtain the git user's authorized_keys.
authorized_keys_command: /usr/local/bin/gitea-authorized-keys
sshd_dropin_path: /etc/ssh/sshd_config.d/60-gitea.conf
sshd_service: ssh
sudoers_path: /etc/sudoers.d/gitea-ssh-passthrough
# ---------------------------------------------------------------------------
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml
# ---------------------------------------------------------------------------
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }}
}