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.
27 KiB
Gitea (rootless Podman + Quadlet)
Runs Gitea on emily as a rootless Podman container managed by a systemd
user unit (Quadlet), behind the shared rootless Caddy,
with git-over-SSH still arriving on the host's own port 22 so that no client
has to change its remote. Replaces the docker_container deployment on monoco.
Two things are worth knowing before anything else:
- This Gitea is SQLite. monoco's live
app.inisaysDB_TYPE = sqlite3,PATH = /data/gitea/gitea.db, so there is nopostgresrole here and nothing to restore into the shared cluster. The unused Docker-era Postgres variant that used to sit next to the playbook has been deleted. - There is no sshd inside the container any more, and the reason is not a preference. Why is the heart of this readme.
Ordering
- caddy/playbook.yml — the reverse proxy, which owns the Caddyfile this site appends to and is the one play that applies the rootless-Podman prerequisites.
- The data copy below — before the first run of this playbook. Gitea writes
into
~/podman-data/giteathe moment it starts, and a first start on an empty directory serves the install wizard instead of this repository's Gitea. - This playbook — run it after the file copy, not before.
postgres/playbook.yml is not part of this ordering. There is no postgres
role in this playbook and no database to attach.
Layout
gitea/
├── playbook.yml
├── vars/default.yml
└── roles/
├── ssh/ # the host half of git-over-SSH: git user, shim, sudoers, sshd drop-in
├── gitea/ # the data tree the image cannot create, the Quadlet unit, start + verify
└── caddy/ # appends this site's block to the shared Caddyfile
| On the host | What |
|---|---|
~/.config/containers/systemd/gitea.container |
the generated unit (Ansible-templated) |
~/.config/containers/systemd/gitea.network |
the app network, written by caddy/roles/register |
~/.config/containers/systemd/caddy.container.d/50-gitea.conf |
attaches Caddy to that network |
~/podman-data/gitea/ |
everything Gitea owns: git/ (repositories, LFS), gitea/ (app.ini, gitea.db, sessions, avatars, logs), git/.ssh/authorized_keys |
~/podman-data/gitea/ssh/ |
not Gitea's: a bare mount point, always empty. The in-image sshd's host keys are mounted over it from gitea-sshd below, which hides it from the container — so nothing ever writes here |
~/podman-data/gitea-sshd/ |
scratch space for the image's sshd, which this deployment does not use — see below |
/home/git/ |
the git login user's home; no .ssh — see The pieces on the host |
/usr/local/bin/gitea |
the passthrough shim sshd's forced command runs |
/usr/local/bin/gitea-container-exec |
podman exec wrapper, run as the podman user |
/usr/local/bin/gitea-authorized-keys |
what sshd calls to get the git user's keys |
/etc/sudoers.d/gitea-ssh-passthrough |
lets git run exactly that one wrapper |
/etc/ssh/sshd_config.d/60-gitea.conf |
Match User git → the keys hook |
How git over SSH works now
What the Docker deployment did — and why it cannot be repeated
client ──ssh git@git.tanshu.com:22──► host sshd (monoco)
│ authenticates against /home/git/.ssh/authorized_keys
│ each line is a forced command:
│ command="/usr/local/bin/gitea --config=/data/gitea/conf/app.ini serv key-5",…
▼
/usr/local/bin/gitea ← shim, runs as host `git`
│ ssh -p 2222 git@127.0.0.1 "SSH_ORIGINAL_COMMAND=… /usr/local/bin/gitea … serv key-5"
▼
sshd inside the container (published 127.0.0.1:2222)
│ authenticated with the host git user's key
│ — the bare, un-forced "Gitea Host Key" line
▼
container's gitea: `gitea serv key-5`
└─ key-5 → the user, in Gitea's database → serve the repo
That works on monoco only because Docker does not remap uids: container-uid
1003 is host-uid 1003, container-root is host-root, so the app, the sshd and
the git login user all share one identity and can read each other's files.
Under rootless Podman with UserNS=keep-id:uid=1003,gid=1003, exactly one
container uid is shared with the host — 1003 ↔ tanshu — and everything else is a
subuid. Verified on emily from the running postgres unit, which uses the same
mechanism:
container 0 → parent 1 → host subuid 165536
container 70 → parent 0 → host tanshu
So the container's own sshd has an impossible job:
| Inside the container | On the host | Consequence |
|---|---|---|
Gitea (1003) |
tanshu |
writes authorized_keys, mode 0600 |
sshd (0) |
subuid 165536 |
cannot read a 0600 tanshu-owned file |
and it cannot be fixed by swapping the mapping: sshd must be root in the
container, while Gitea refuses to be root at all — the image aborts with
mustNotRunAsRoot() [F] Gitea is not supposed to be run as root. There is no uid
that satisfies both, and keep-id can only map one of them onto the host user.
The replacement
The second hop stops being an ssh and becomes a podman exec, run as
tanshu, the user who owns the container. The public entry point — the host's
sshd on port 22 — is unchanged, and so is every command= line Gitea has already
written, because the passthrough shim keeps its path and its arguments:
client ──ssh git@git.tanshu.com:22──► host sshd (emily)
│ keys from gitea-authorized-keys (AuthorizedKeysCommand,
│ because StrictModes refuses a file git does not own)
▼
/usr/local/bin/gitea ← shim, runs as host `git`
│ sudo -n -u tanshu /usr/local/bin/gitea-container-exec \
│ "$SSH_ORIGINAL_COMMAND" --config=… serv key-5
▼
podman exec -i --user 1003:1003 … gitea \
/usr/local/bin/gitea --config=/data/gitea/conf/app.ini serv key-5
└─ env SSH_ORIGINAL_COMMAND="git-upload-pack '…'"
key-5 → the user → serve the repo
The identity still travels in the command string (key-5), so Gitea's per-user
authorisation is exactly what it was. What is gone: the 2222 publish, the
container's sshd as a hop, /data/ssh in the data tree, and the "Gitea Host Key"
that only existed to authenticate that hop.
Why there is no sshd in the container
- sshd must run as the container's root, which under
keep-idis a subuid. - Gitea refuses to run as root, so it runs as container-uid 1003 =
tanshu. - Gitea maintains
authorized_keysitself, astanshu, mode0600. - A subuid cannot read it.
StrictModeswould also reject it. - Rewriting the modes is not a fix:
sshdignores world-readable host keys, and the file is rewritten by Gitea after every key change. podman execneeds no uid to be shared with the container at all, so it works.
The image still starts an sshd of its own (/etc/s6/openssh), because that is
baked into the image. It is unpublished and nothing connects to it, but it does
want host keys, and it cannot write them into the data tree for the same subuid
reason. It therefore gets ~/podman-data/gitea-sshd, a scratch directory mounted
over /data/ssh outside the data tree — so no subuid-owned file ever lands in
~/podman-data/gitea, a plain tar of the data tree stays complete, and the
play's "nothing in here belongs to another user" check stays meaningful.
If you prefer no stray directory at all, a tmpfs works in principle:
Mount=type=tmpfs,destination=/data/ssh,tmpfs-mode=1777instead of theVolume=line. Not verified on emily — try it in a scratch copy of the unit and watchpodman logs giteafor the image's openssh service coming up.
The pieces on the host
The git login user exists only so sshd has someone to authenticate as. It
has /bin/bash because sshd runs a forced command through the login shell
(/bin/bash -c '…'); a nologin shell would break git-over-SSH outright. Its uid
is deliberately not pinned: on monoco it was 1003 because it had to equal the
container's git uid, and under keep-id that pairing no longer exists.
There is no /home/git/.ssh. It used to hold the shim's client key (the
"Gitea Host Key", now obsolete — nothing authenticates over ssh any more) and was
bind-mounted into the container as /data/git/.ssh. The file that matters now
lives in the data tree, where Gitea writes it. The migration below moves it there.
/usr/local/bin/gitea is the shim. Its path is a contract: it is baked into
every command= line Gitea has already written, so renaming it locks every user
out until Gitea rewrites the file.
/usr/local/bin/gitea-container-exec is the podman exec, with the uid,
HOME, GITEA_CUSTOM, SSH_ORIGINAL_COMMAND and XDG_RUNTIME_DIR that the
container's sshd used to provide. XDG_RUNTIME_DIR is set explicitly because
sudo -u creates no PAM session — the same trick
caddy/readme.md shows for sudo -u … systemctl --user.
/etc/sudoers.d/gitea-ssh-passthrough grants git exactly one command as
tanshu. It is installed with visudo -cf validation, because a syntax error in
/etc/sudoers.d does not break one rule, it breaks every sudo on the host.
The trust boundary is worth stating plainly: whoever can obtain a git shell on
this host can run containers as tanshu. Nothing gets that shell — every key
Gitea accepts carries a forced command — and the hook below is what keeps it that
way.
/etc/ssh/sshd_config.d/60-gitea.conf points sshd at
gitea-authorized-keys for Match User git. It has to be a hook rather than the
plain file because the file belongs to tanshu, which is exactly what
StrictModes refuses — and StrictModes cannot be relaxed for one user:
OpenSSH rejects it inside a Match block (Directive 'StrictModes' is not allowed within a Match block, confirmed on emily, OpenSSH 10.2p1).
AuthorizedKeysCommand output is used without an ownership check, so that is the
mechanism. The drop-in is validated with a full sshd -t before sshd is
reloaded, and removed again if it does not parse.
/usr/local/bin/gitea-authorized-keys prints Gitea's file, filtered to the
lines that force the shim. The unforced "Gitea Host Key" line is deliberately
dropped: nothing needs it now, and printing it would hand an interactive git
login — and with it rootless Podman, via the sudoers rule — to anyone holding a
copy of that key, a backup included.
Who runs as whom
UserNS=keep-id:uid=1003,gid=1003 plus Environment=USER_UID/USER_GID=1003, all
three from gitea_uid in vars/default.yml. Gitea therefore
writes as container-1003, which is tanshu on the host: everything under
~/podman-data/gitea is tanshu-owned, and plain tar/rsync backups keep
working.
User=0 is the fourth line, and without it nothing starts. keep-id does not
only map the uid, it also runs the container process as it — 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 (sourced
by /etc/s6/gitea/run before it su-exec gits the server) prepares /data, and
the image's own CMD is s6-svscan /etc/s6, which has to create
.s6-svscan/lock inside root-owned /etc/s6. Denied all three, the container
exited immediately and Restart=always turned that into a crash loop — which
is-active reports as activating, not as a failure. User=0 restores root for
the start-up and changes nothing about the mapping above: the server still runs
as git through su-exec, which is what satisfies Gitea's own
mustNotRunAsRoot check. (nextcloud.container carries the same pair for the
same reason.)
The consequence to internalise: container root is a subuid on the host. It
holds the usual capabilities inside its user namespace, so the start-up's
mkdir, chmod and chown all succeed — and what they create is left owned by
that subuid. The image chowns the directories it knows about back to git
(chown -R ${USER}:git /data/gitea and /data/git in /etc/s6/gitea/setup), but
not everything, so roles/gitea creates the tree up
front — gitea/, gitea/conf/, gitea/log/, git/, git/.ssh/, ssh/ — owned
by tanshu, with the modes those scripts would have set. Two of them are not
housekeeping at all:
git/.ssh/environmentis written by the play rather than left to the setup script, because the setup's copy is the one file under/datait never chowns back togit.ssh/is the mount point for the in-image sshd's host keys. It is hidden inside the container by that mount, so nothing ever chowns it: if Podman has to create it, it lands in the data tree owned by the subuid, and the check at the end of the role fails on it. That is exactly what happened before this was added.
This is the same trap postgres/readme.md documents for
PGDATA, arrived at from the other direction.
When it does not start
The container's start-up output is in the journal, and the play prints it for you:
every step from Start Gitea through the health check is in one block whose
rescue dumps systemctl status, journalctl and podman logs. The lines that
have actually shown up, and what each means:
| In the journal | Cause |
|---|---|
s6-svscan: fatal: unable to open .s6-svscan/lock: Permission denied |
the container process is not root — User=0 is missing |
sed: can't create temp file '/etc/passwdXXXXXX': Permission denied |
same cause: the entrypoint may not rewrite /etc/passwd |
DB not initialized / the install page in a browser |
the copy from monoco has not run, so this is an empty Gitea |
Beyond those, podman logs gitea on a healthy container is clean — no
permission errors at all. An earlier draft of this readme listed four
chmod … Operation not permitted lines as expected noise; they do not occur, and
if you see something like them they are worth reading rather than skimming.
Settings
| Variable | Purpose |
|---|---|
podman_user, podman_data_dir, quadlet_dir |
Rootless-Podman contract, duplicated from caddy/vars/default.yml |
container_name |
Container, unit and network prefix — gitea |
image |
docker.io/gitea/gitea:latest, fully qualified on purpose |
host_directory |
Where all state lives (~/podman-data/gitea) |
container_port |
3000, the port inside the container Caddy proxies to |
gitea_uid / gitea_gid |
1003 — the image's own git account. Drives UserNS, USER_UID/USER_GID and the wrapper's --user; see Who runs as whom |
gitea_custom |
/data/gitea, the image's GITEA_CUSTOM |
timezone |
TZ for log timestamps only; Gitea stores each user's own timezone |
health_cmd |
The unit's HealthCmd — /api/healthz, Gitea's unauthenticated probe |
container_sshd_hostkeys |
Scratch dir for the image's unused sshd, mounted over /data/ssh |
git_user, git_home |
The SSH login account (git, /home/git) |
shim_path |
/usr/local/bin/gitea — a contract, not a knob |
podman_exec_wrapper, authorized_keys_command |
The two helper scripts |
sshd_dropin_path, sshd_service, sudoers_path |
/etc/ssh/sshd_config.d/60-gitea.conf, ssh, /etc/sudoers.d/gitea-ssh-passthrough |
host, caddy_name, caddyfile_path, snippet_block |
The Caddy side, as in every service playbook |
Keep the three podman_*/quadlet_dir vars in sync with
caddy/vars/default.yml — they are duplicated rather
than imported because a play cannot include Caddy's vars file wholesale (it also
sets host_directory, which every service defines for itself).
Running
ansible-playbook -u tanshu gitea/playbook.yml
The play targets emily directly, so the inventory must contain it — the one in
prepare-server/ does, and its ansible.cfg names it.
This play applies only this app's units, data directories and host-side SSH
plumbing. The rootless-Podman prerequisites — installing podman,
loginctl enable-linger, the low-port sysctl and the Quadlet directory — are the
shared task file at
caddy/roles/podman/tasks/main.yaml, and
are not run here. A host that has already run caddy/playbook.yml has them.
systemctl --user enable gitea is neither needed nor possible: the unit is
generated, and Quadlet applies [Install] WantedBy=default.target itself.
Migrating the data from monoco
Everything here is about files Gitea owns. There is no database dump to take:
this Gitea is SQLite, and gitea.db travels inside the tree. Run the commands
from the machine that holds the monoco and emily SSH aliases.
Two things in the source tree are deliberately not copied:
/var/lib/gitea/ssh— host keys for the sshd inside the container. That sshd is no longer a hop, nothing connects to it, and the new deployment lets it generate fresh keys in~/podman-data/gitea-sshd. The Docker-era audit told you to copy them so clients would not see host-key warnings; with the container's sshd out of the path, no client ever sees them./home/git/.ssh/id_ed25519and friends — the "Gitea Host Key" that authenticated the old second hop. Nothing authenticates over ssh now. Onlyauthorized_keys(and optionallyenvironment) is carried across, into the container's data tree, which is where Gitea expects to maintain it.
# 1. Look before you copy: what is there, and how big is it. `ls -ln` shows
# numeric owners, because the old container wrote as uid 1003 (`git`).
ssh monoco 'du -sh /var/lib/gitea/*; ls -ln /var/lib/gitea'
ssh monoco 'sudo ls -ln /home/git/.ssh'
# 2. Stop the old container so nothing writes while you copy. SQLite in WAL mode
# is exactly the thing the backup plan warns about — a live copy can be torn.
# Leave it stopped: emily is about to become the only writer, and two live
# copies behind the same hostname would diverge.
ssh monoco 'sudo docker stop gitea'
# 3. Create the destination tree (the playbook would create it too, but the copy
# needs somewhere to land).
ssh emily 'mkdir -p ~/podman-data/gitea/git/.ssh'
# If the playbook has already run here — it says so when it does — the app has
# left a fresh `gitea/conf/app.ini` (and possibly logs) behind, and the digests
# in step 6 will not match. Clear it first:
# ssh emily 'systemctl --user stop gitea.service; find ~/podman-data/gitea/gitea ~/podman-data/gitea/git -mindepth 1 -delete'
# 4. Copy the data tree, minus the obsolete container-sshd host keys.
# tar streams it across the workstation; rsync cannot copy remote-to-remote in
# one command.
#
# `set -o pipefail` matters: without it the pipeline reports only the
# *receiving* tar's status, so a read error or a dropped stream on the source
# side exits 0 and leaves a silently short copy.
#
# The source needs sudo — the files belong to `git`, not to you.
set -o pipefail
ssh monoco 'sudo tar --exclude="./ssh" -C /var/lib/gitea -cf - .' \
| ssh emily 'tar --no-same-owner -C ~/podman-data/gitea -xf -'
# 5. Copy the authorized_keys Gitea maintains. It lives outside /var/lib/gitea on
# the old host, because it was bind-mounted in from the git user's home.
set -o pipefail
ssh monoco 'sudo tar -C /home/git/.ssh -cf - authorized_keys environment' \
| ssh emily 'tar --no-same-owner -C ~/podman-data/gitea/git/.ssh -xf -'
# 6. Verify by content, over both trees, with the same exclusions on each side so
# the digests are comparable. `.ssh` is excluded here because it is compared
# on its own in the next step, and `ssh/` never left monoco.
ssh monoco 'cd /var/lib/gitea && sudo find ./git ./gitea ! -path "./git/.ssh*" -type f -print0 \
| sort -z | xargs -0 -r sudo sha256sum | sha256sum'
ssh emily 'cd ~/podman-data/gitea && find ./git ./gitea ! -path "./git/.ssh*" -type f -print0 \
| sort -z | xargs -0 -r sha256sum | sha256sum'
# identical digests = a byte-faithful copy
ssh monoco 'sudo sha256sum /home/git/.ssh/authorized_keys | cut -d" " -f1'
ssh emily 'sha256sum ~/podman-data/gitea/git/.ssh/authorized_keys | cut -d" " -f1'
# 7. Nothing may end up owned by root or by a subuid, and sshd's hook must be
# able to read the file it is about to be handed.
ssh emily 'find ~/podman-data/gitea ! -user tanshu' # empty = correct
ssh emily 'ls -ln ~/podman-data/gitea/git/.ssh/authorized_keys'
# 8. Deploy — after the copy, so Gitea finds the real data on first start.
cd prepare-server && ansible-playbook -u tanshu ../gitea/playbook.yml
If the link is slow or drops, do it in two hops instead: resumable, and it shows progress.
rsync -aP --exclude='ssh' monoco:/var/lib/gitea/ ~/gitea-copy/
rsync -aP ~/gitea-copy/ emily:~/podman-data/gitea/
rsync -aP monoco:'/home/git/.ssh/{authorized_keys,environment}' ~/gitea-ssh-copy/
rsync -aP ~/gitea-ssh-copy/ emily:~/podman-data/gitea/git/.ssh/
Rolling back means rolling back all of it: ssh monoco 'sudo docker start gitea'
is only meaningful while monoco's data is still intact, and it would then be
writing to a gitea.db that emily's copy has already moved past. Never run
both at once.
Verification
systemctl --user status gitea
journalctl --user -u gitea -f
podman logs gitea # clean — see "When it does not start" above
# What the playbook checks for you:
podman healthcheck run gitea # /api/healthz
find ~/podman-data/gitea ! -user tanshu # empty
# The unit is where it should be, and the image's sshd has its scratch space:
podman inspect gitea --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
# The host's SSH half, piece by piece:
sudo sshd -T -C user=git | grep -i authorizedkeys # the hook, and only for git
sudo -u tanshu /usr/local/bin/gitea-authorized-keys | head -3
sudo -u tanshu /usr/local/bin/gitea-authorized-keys | grep -c '^ssh-' # 0 — the bare host-key line is dropped
sudo visudo -cf /etc/sudoers.d/gitea-ssh-passthrough
# the wrapper reaches the container as the right user:
sudo -u tanshu /usr/local/bin/gitea-container-exec "" /usr/local/bin/gitea --version
# and the git user is allowed to run exactly that wrapper:
sudo -u git sudo -n -u tanshu /usr/local/bin/gitea-container-exec "" /usr/local/bin/gitea --version
# The site, end to end.
curl -sSI https://git.tanshu.com
Then, from a client whose key is registered in Gitea:
ssh -T git@git.tanshu.com # "Hi <user>! You've successfully authenticated…"
git clone git@git.tanshu.com:tanshu/playbooks.git
and push to a repository you own — that is the check that per-user authorisation
still works, because the identity travels in the serv key-N argument and not
in the shim's own credentials. Then exercise the web UI and confirm a repository
list renders, which is the check that gitea.db came across.
Finally, reboot the host and confirm the unit returns unattended — without
loginctl enable-linger tanshu it will not, and the failure is silent.
Notes and gotchas
DISABLE_SSHmust stayfalse, and Gitea's built-in SSH server must stay off (START_SSH_SERVERabsent orfalse) inapp.ini. Gitea only maintainsauthorized_keysfor an external sshd — which the host's sshd is here. Turn the built-in server on and Gitea stops writing the file the host's sshd reads, and git-over-SSH stops working with no error anywhere obvious. The copiedapp.inialready has this right.SSH_PORT = 22stays as it is. It is what makes Gitea offergit@git.tanshu.com:user/repo.gitrather than a URL with a port in it, and the host's sshd is what answers on 22.SSH_LISTEN_PORT = 22refers to the container's own sshd port, which this deployment does not use.- The
gituser can reach rootless Podman astanshuthrough one sudoers rule. That is the price of a two-user design and it is bounded to one root-owned wrapper; see The pieces on the host. - The authorized_keys filter is a format dependency. If Gitea ever stops
writing
command="/usr/local/bin/gitea …lines, the hook prints nothing and git-over-SSH breaks while HTTPS keeps serving. Diagnose withsudo -u tanshu /usr/local/bin/gitea-authorized-keysandpodman exec gitea cat /data/git/.ssh/authorized_keys. - Pushing over SSH runs no hooks through the host user. Hooks execute inside
the container, as
git=tanshu, so a repository hook can write anywheretanshucan. Same as before the move, but worth knowing before enabling server-side hooks. latestfloats. Neither the old playbook nor this one pins a digest, so an image bump is a normalpodman pullaway and may run new database migrations on first start.- The old backup script's
docker exec -u git gitea gitea dump …needs rewriting for this deployment:sudo podman execfrom root's crontab cannot enter another user's rootless containers, so anything scheduled belongs in a systemd user timer next to the Quadlet units. See the Backups section of postgres/readme.md for the shape of it. - DNS.
git.tanshu.commust resolve toemilybefore Caddy can obtain a certificate for it. - Do not restart the container on monoco while emily is serving. It would write to the old data tree, which is being kept intact purely as a rollback.
Not done here
- Rewriting the backup script for rootless Podman and moving it to a systemd user timer (above).
- Rotating the secrets that live in
app.iniin plaintext —SECRET_KEY,INTERNAL_TOKEN,LFS_JWT_SECRET,JWT_SECRET— and the fleet-wide123456-style passwords elsewhere in this repo. - Templating
app.ini. Gitea's own configuration travels with the data, and it carries those secrets; a fresh instance with no data gets the image's install wizard instead, which is a reasonable outcome but not a supported path here.