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

+480 -35
View File
@@ -1,45 +1,490 @@
# Gitea on Ubuntu
# Gitea (rootless Podman + Quadlet)
This playbook will install Gitea on an Ubuntu 19.10 machine.
A number of containers will be created with the options specified in the `vars/default.yml` variable file.
Runs Gitea on `emily` as a **rootless Podman** container managed by a **systemd
user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md),
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.ini` says `DB_TYPE = sqlite3`,
`PATH = /data/gitea/gitea.db`, so there is no `postgres` role 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](#why-there-is-no-sshd-in-the-container) is the heart of this
readme.
## Ordering
1. [caddy/playbook.yml](../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.
2. **The data copy below — before the first run of this playbook.** Gitea writes
into `~/podman-data/gitea` the moment it starts, and a first start on an empty
directory serves the *install wizard* instead of this repository's Gitea.
3. 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](#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-id` is a subuid.
- Gitea refuses to run as root, so it runs as container-uid 1003 = `tanshu`.
- Gitea maintains `authorized_keys` itself, as `tanshu`, mode `0600`.
- A subuid cannot read it. `StrictModes` would also reject it.
- Rewriting the modes is not a fix: `sshd` ignores world-readable host keys, and
the file is rewritten by Gitea after every key change.
- `podman exec` needs 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=1777` instead of the
> `Volume=` line. **Not verified on emily** — try it in a scratch copy of the unit
> and watch `podman logs gitea` for 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](../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](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 git`s 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](roles/gitea/tasks/main.yaml) 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/environment`** is written by the play rather than left to the setup
script, because the setup's copy is the one file under `/data` it never chowns
back to `git`.
- **`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](../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
- `create_containers`: number of containers to create.
- `default_container_name`: default name for new containers.
- `default_container_image`: default image for new containers.
- `default_container_command`: default command to run on new containers.
| Variable | Purpose |
|---|---|
| `podman_user`, `podman_data_dir`, `quadlet_dir` | Rootless-Podman contract, duplicated from [caddy/vars/default.yml](../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](#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](../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 this Playbook
Quick Steps:
### 1. Obtain the playbook
```shell
git clone https://github.com/do-community/ansible-playbooks.git
cd ansible-playbooks/docker_ubuntu1804
```
### 2. Customize Options
```shell
nano vars/default.yml
```
```yml
#vars/default.yml
---
create_containers: 4
default_container_name: docker
default_container_image: ubuntu
default_container_command: sleep 1d
```
### 3. Run the Playbook
## Running
```command
ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml
ansible-playbook -u tanshu gitea/playbook.yml
```
For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04).
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](../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_ed25519` and friends** — the "Gitea Host Key" that
authenticated the old second hop. Nothing authenticates over ssh now. Only
`authorized_keys` (and optionally `environment`) is carried across, into the
container's data tree, which is where Gitea expects to maintain it.
```bash
# 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.
```bash
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
```bash
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:
```bash
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_SSH` must stay `false`, and Gitea's built-in SSH server must stay
off** (`START_SSH_SERVER` absent or `false`) in `app.ini`. Gitea only maintains
`authorized_keys` for 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 copied
`app.ini` already has this right.
- **`SSH_PORT = 22` stays as it is.** It is what makes Gitea offer
`git@git.tanshu.com:user/repo.git` rather than a URL with a port in it, and the
host's sshd is what answers on 22. `SSH_LISTEN_PORT = 22` refers to the
container's own sshd port, which this deployment does not use.
- **The `git` user can reach rootless Podman as `tanshu`** through 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-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 with
`sudo -u tanshu /usr/local/bin/gitea-authorized-keys` and
`podman 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 anywhere
`tanshu` can. Same as before the move, but worth knowing before enabling
server-side hooks.
- **`latest` floats.** Neither the old playbook nor this one pins a digest, so an
image bump is a normal `podman pull` away 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 exec` from 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](../postgres/readme.md) for the shape of it.
- **DNS.** `git.tanshu.com` must resolve to `emily` before 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.ini` in plaintext — `SECRET_KEY`,
`INTERNAL_TOKEN`, `LFS_JWT_SECRET`, `JWT_SECRET` — and the fleet-wide
`123456`-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.