Gitea
pg addon install gitea runs Gitea — the
lightweight self-hosted Git service — as a managed container from the official
upstream image (docker.io/gitea/gitea, rootful build). Like Grafana it is a
top-level addon: it is not attached to any one PostgreSQL instance — it fronts
none, though it can use one as its metadata database (see --dsn below).
One container serves the web UI, the Git HTTP traffic, and git-over-ssh on the
image’s OpenSSH sshd (see Git SSH — that sshd runs on
every instance whether or not --ssh-port is set; the flag only makes it the
live clone endpoint).
The install is fully non-interactive: INSTALL_LOCK=true skips Gitea’s web setup wizard, and the admin account is created afterwards via the in-container gitea admin user create CLI. The web login password is generated on first install, printed once, and stored in pg.yaml; retrieve it later with pg addon password gitea. Listen defaults to 0.0.0.0 — a Git host is cloned from other machines — and the exposure is guarded by the generated strong password plus closed self-registration; pass --listen 127.0.0.1 for loopback-only access.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the web port — and, when enabled, the SSH port — published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
gitea |
Instance name (map key) |
--image |
docker.io/gitea/gitea:28.1.0 |
Image override (rootful build only — see Known limitations) |
--port |
auto (base 3080) | Host web port; auto-allocated from gitea_start_port |
--ssh-port |
unset | Make the always-running sshd the live git-over-ssh endpoint on this host port (see Git SSH). A port is allocated from gitea_ssh_start_port (base 2222) for every instance to park sshd off 22, SSH on or off; --ssh-port 0 disables live cloning but keeps the reservation |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only. Also the host Gitea renders into clone URLs |
--admin-user |
gitea |
Web login user (deliberately not git — that is the container’s OS user ssh:// clones authenticate as) |
--password |
auto-generated | Web login password (applied only when the admin account does not yet exist — see below) |
--dsn |
empty | postgres://user:pass@host:port/db to keep Gitea’s metadata in an external PostgreSQL instead of the embedded SQLite; the database must already exist |
--allow-registration |
false | Open self-service signup (the web UI shows a Register entry). Closed by default — see Listening and exposure |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — to apply changed ports, credentials, dsn, listen address or the registration toggle |
Install
The command pulls the image if needed, creates and starts the container, waits for Gitea’s first-boot migrations to finish, creates the admin account, and prints:
Open the URL, sign in with the printed credentials, and create your first org/repo in the web UI. Clone over HTTP with the same login:
The data directory and /data
Everything Gitea owns lives in one bind-mounted directory: the repositories, the rendered /data/gitea/conf/app.ini, the SQLite metadata database (/data/gitea/gitea.db — absent with --dsn), the SSH host keys, and logs. It survives pg addon remove (delete it with --clean-data), so reinstalling under the same name revives everything.
The image’s rootful build runs as the git user — uid/gid 1000, pinned via USER_UID/USER_GID so the host-side pre-chown and the container process can never drift apart — and its entrypoint only chowns what it creates itself, so pgcli chowns the data dir on the host before creating the container: via podman unshare chown 1000:1000 under rootless podman, or a direct recursive chown when pgcli runs as root on a rootful host.
pgcli never injects SECRET_KEY or INTERNAL_TOKEN. Gitea folds every GITEA__<section>__<KEY> environment variable into the persisted app.ini on each boot; if pgcli regenerated and injected crypto secrets, a reinstall over a kept data dir would re-key the persisted app.ini while the surviving database still holds 2FA seeds and OAuth client secrets encrypted under the old key — a silent, unlogged lockout. So those keys belong to the volume: Gitea generates them itself on first boot, and pgcli owns only the admin login password.
The admin password takes effect only when the admin account does not exist in the database — it is created with gitea admin user create, never reset. Reinstalling over a kept data dir (or a kept --dsn database) keeps the original login; the printed password is then inert (install says so explicitly). To change the login of an existing store, run gitea admin user change-password inside the container (pg exec <instance> -- ... style: podman exec -it <container> gitea admin user change-password ...), or remove with --clean-data and reinstall (SQLite mode — a --dsn database also has to be dropped by hand).
Re-running install reuses a running container and starts a stopped one — config changes only apply after a remove + reinstall or --force.
Database: SQLite or --dsn
By default Gitea uses its embedded SQLite database inside /data — zero dependencies, one file, revived with the data dir. For anything shared or long-lived, point the metadata at PostgreSQL:
pgcli validates the DSN before writing anything and renders it into GITEA__database__DB_TYPE=postgres plus HOST/NAME/USER/PASSWD. The install summary and pg addon list echo it back with the credentials hidden (host:port/db). Same contract as PostgREST’s --dsn: the database must already exist (create it on a managed instance with pg exec <instance> -c 'CREATE DATABASE gitea ...' or any other way); Gitea creates its own schema on first boot. pg addon remove gitea — even with --clean-data — never touches the external database; it only holds Gitea’s tables.
Git SSH (optional)
Every Gitea instance runs an OpenSSH sshd — the official image’s s6
openssh service is unconditional (its run script execs sshd; no
GITEA__server__* env turns it off). What --ssh-port actually toggles is
whether that sshd is the live git-over-ssh clone endpoint or just an idle
listener. --ssh-port takes a host port from gitea_ssh_start_port (base
2222) for every instance — SSH on or off — precisely so the always-on
sshd is parked off 22: on the host network, leaving it on 22 would race
the host’s own sshd and the log would fill with Bind to port 22 … Address in use forever. pgcli moves it by setting the image’s bare SSH_LISTEN_PORT
/ SSH_PORT env (those are what the rendered sshd_config’s Port line
actually consumes — GITEA__server__SSH_LISTEN_PORT alone only changes
what Gitea believes the port is, not what sshd binds).
- SSH off (default, no
--ssh-port):DISABLE_SSH=trueso Gitea neither writes the git user’sauthorized_keysnor advertisesssh://clone URLs. The s6sshdis still running, parked on a pool port, and no client will ever reach it. The pool reservation stays — nothing frees it. - SSH on (
--ssh-port N, N>0): git-over-ssh rides that same s6 sshd — the image’s native route.DISABLE_SSH=falsemakes Gitea populate the git user’sauthorized_keys(which sshd reads viaAuthorizedKeysFilepointed into/data/gitea/ssh/); Gitea’s own built-in Go SSH listener is pinned off withSTART_SSH_LISTENER=falseso it does not double-bind the same port in the same netns; andSSH_DOMAIN/SSH_PORTrender thessh://git@<host>:N/<owner>/<repo>.gitclone URL.
Auth is the git account’s SSH keys added through the web UI (the OS user
is git; the web admin is gitea). Host keys live under
<data>/ssh/, so they survive reinstalls — if you remove with
--clean-data, clients see a new-host-key warning the next time.
Under the macOS bridge the SSH port is additionally published, but only when
SSH is on — a parked sshd is unreachable by design, so it gets no -p.
Not passing --ssh-port on a later install keeps the stored setting;
--ssh-port 0 disables live cloning (the port reservation stays — only the
DISABLE_SSH flip and the clone-URL change take effect, with a --force
recreate).
Listening and exposure
Gitea’s Listen defaults to 0.0.0.0: the container shares the host’s network namespace, so the web UI and Git HTTP are reachable on every interface at the assigned port. This is deliberate — a Git host is cloned from other machines — and is protected by the generated strong admin password plus DISABLE_REGISTRATION=true (self-service account creation is off; create accounts with pg addon gitea user create). For a loopback-only deployment pass --listen 127.0.0.1 (recreate with --force).
--allow-registration flips that second guard: pg addon install gitea --name <n> --allow-registration --force renders DISABLE_REGISTRATION=false and the web UI grows a Register entry. Close it again with --allow-registration=false --force — the toggle is a stored config value gated on the flag being passed, so a plain re-install (or one without the flag) keeps whatever state is stored, and only --force pushes a change into the live container. What opens up is account spam, not takeover: a field test confirmed the register form creates a NORMAL user (IsAdmin=false) — Gitea has no way to make signup produce an admin; elevation is exclusively pg addon gitea user create --admin or the admin panel, independent of how the account was born. Companion Gitea-side hardening for an open-registration site lives in the same service section (REGISTER_EMAIL_CONFIRM, CAPTCHA, REQUIRE_SIGNIN_VIEW) — outside pgcli’s rendered env set, so it stays Gitea-side configuration.
--listen is also what displayed clone URLs use: a wildcard bind is not a connectable address, so it is echoed as localhost, and any explicit host/IP appears verbatim in ROOT_URL and the ssh:// URLs. When Gitea sits behind a reverse proxy that forwards X-Forwarded-Proto and Host — which pgcli’s nginx template does by default — Gitea rewrites the displayed clone URL to match the incoming request, so the code button on the repo page reads https://<proxy-host>/<owner>/<repo>.git correctly (scheme from X-Forwarded-Proto, host from Host, standard port dropped). pgcli’s rendered ROOT_URL stays http://<host>:<web-port>/ because there is no config face to advertise https/the proxy port — but that only governs webhook/redirect URLs generated server-side; interactive clone URLs follow the request. For TLS, see HTTPS via nginx.
HTTPS via nginx
Gitea serves plaintext HTTP; to reach it over https:// you front it with the
nginx addon, which terminates TLS and proxies to the
Gitea web port. Two things to get right: bring your own certificate (or let
pgcli mint a self-signed one), and use nginx’s custom --conf-file mode —
the template’s default client_max_body_size (1 MiB) rejects any larger
git push with HTTP 413.
Write the proxy config as /srv/gitea-nginx.conf — the parts that matter for
Git are client_max_body_size 10m (nginx’s built-in default is 1 MiB, and
without raising it a larger git push dies with HTTP 413 before reaching
Gitea) and the long proxy_*_timeout (streaming git push outlives the 60s
default). The same file as a generic template also lives in the nginx docs
(“Git HTTP push with client_max_body_size”):
The nginx proxy now serves https://10.0.0.9/ and forwards to Gitea’s
127.0.0.1:3080. Clone and push over the proxy URL (hand clients the .crt as
their CA so the self-signed cert verifies):
Notes:
- The Gitea web port (3080) is still open directly; the proxy does not hide it.
For an origin that only nginx may reach, reinstall Gitea with
--listen 127.0.0.1so it binds loopback only, and point the proxy’s backend at127.0.0.1:3080(same host) — works because both run on the host network. pg addon nginx reloadwill not pick up an external edit to the mountednginx.conf(the bind mount pins the original inode, so ased -i/editor rewrite is invisible until the container restarts). Reload only applies changes pgcli itself wrote. After hand-editing the conf, restart withpg addon stop nginx --name proxy && pg addon start nginx --name proxy, or reinstall with--force.- A real CA cert (Let’s Encrypt / ZeroSSL / corporate) is the right choice in
production — see Production domain + a real CA below; it drops the
--cacerthand-off entirely, browsers and git trust it out of the box. - git-over-ssh (
--ssh-port) is independent of this; TLS here only fronts the web UI and Git-over-HTTP.
Production domain + a real CA (Let’s Encrypt / corporate)
For anything facing the internet, don’t ship a pg cert self-signed leaf —
point a real domain at the host (e.g. git.example.com → 203.0.113.7) and get a
CA-signed certificate for it. How you obtain that certificate is outside
pgcli’s scope — any CA and any tool you like; all that matters is ending up
with a PEM cert (including its intermediate chain) and its private key.
Then create the nginx proxy exactly as in the steps above, only swapping the
certificate paths for your domain cert — the same --conf-file, ports and
--tls are unchanged:
Set --listen git.example.com on the Gitea install too (with --force), so
ROOT_URL and any ssh:// hints carry the real hostname. Once it’s up, users
clone https://git.example.com/<owner>/<repo>.git with no CA hand-off —
browsers and git trust public CAs by default.
Remember to replace the certificate before it expires. pgcli copies the BYO
pair into the addon’s TLS dir (<base>/addon/nginx/<name>/tls/ as
public.crt / private.key) at install, and that directory is bind-mounted
into the container — so renewal is: overwrite those two files, then
(Verified: the served certificate changes immediately after the reload. This
works because the TLS dir is mounted as a directory; unlike the single-file
nginx.conf mount, new contents are visible to the running container.)
Retrieving the web login
The login is Gitea’s web/Git-HTTP sign-in, unrelated to any PostgreSQL role (even with --dsn). Re-read the password at any time:
It is also stored in pg.yaml (addons.gitea.<name>.password).
Managing accounts (members and admins)
Self-registration is closed, so every account beyond the install admin is created
by an admin. pg addon gitea user wraps the in-container gitea admin user …
CLI (run as the git uid automatically) so you manage accounts from the host
without a manual podman exec. The instance must be running.
flag (create) |
default | meaning |
|---|---|---|
--username |
(required) | the account name |
--email |
<username>@localhost |
pass a real address when the user should get notifications / password resets |
--password |
generated | pin the login, or let pgcli generate a strong one and print it once |
--admin |
false | create a site admin instead of a normal member |
--must-change-password |
false | Gitea’s own default is true for every account after the first; that forces a change on first login and, until it happens, returns 403 for HTTP clone/push and API basic-auth. pgcli sends =false explicitly so a member can clone immediately — set it true only to hand out a one-time password |
These logins are Gitea’s own accounts, not pgcli-managed, so a generated
password is printed once and is not written to pg.yaml — record it at
creation. (Only the install admin’s login is stored; that one you retrieve with
pg addon password gitea.) To change an existing account’s password, run
gitea admin user change-password inside the container.
delete refuses to remove the install admin (it is pgcli’s stored login). A
plain delete only succeeds for an account that owns nothing; if it still owns
repositories Gitea refuses with user still has ownership of repositories —
pass --purge to remove the account and everything it owns (repos, orgs,
comments).
Ports
Each Gitea instance takes one port for the web UI, allocated from a pool with base gitea_start_port (default 3080 — Gitea’s conventional 3000 is the Grafana pool’s base in pgcli, so Gitea starts one pool over; still recognizably “the Gitea port”). Every instance also takes a port from a second pool (gitea_ssh_start_port, default 2222 — the conventional Gitea SSH mapping) to park the image’s always-running sshd off 22; whether SSH is on or off only decides whether that port is a live clone endpoint or an idle listener, so the reservation is held either way. Both pools skip ports already taken by any managed container or the used_ports list, and the allocations are written into pg.yaml:
The two pools’ bases (3080 / 2222) are disjoint from every other addon’s pool.
Configuration
Config is env-only (GITEA__<section>__<KEY> plus two bare
SSH_LISTEN_PORT / SSH_PORT vars — those are what the image’s always-running
sshd actually binds; the GITEA__server__* copy only drives Gitea’s own
SSH-aware clone-URL rendering), folded by the image into app.ini on every
boot; the app.ini inside /data is rewritten from these variables, so
hand-editing it is not a supported configuration path (settings pgcli does not
set survive the rewrite, but changing one of pgcli’s own keys by hand just gets
undone on the next boot). The variables pgcli sets are: the wizard skip
(INSTALL_LOCK), the listener (HTTP_PORT/HTTP_ADDR), the display URL
(ROOT_URL), the registration gate (DISABLE_REGISTRATION, rendered in both
states — true by default, false with --allow-registration, since Gitea’s
own default is open and an omitted line could not override a persisted one),
the always-emitted
bare SSH_LISTEN_PORT/SSH_PORT pair that parks the s6 sshd off 22,
DISABLE_SSH=true when SSH is off (Gitea then also does not write
authorized_keys or show ssh:// URLs) or — when SSH is on — DISABLE_SSH=false
plus the URL-rendering trio SSH_LISTEN_PORT/SSH_PORT/SSH_DOMAIN under
GITEA__server__ and START_SSH_LISTENER=false so Gitea’s own Go listener
does not double-bind the same port, the optional database block, and the process
uid pin (USER_UID/USER_GID).
List
Passwords are hidden by default to keep the listing paste-safe; pg addon list --show-password reveals the plaintext value, and pg addon password gitea prints a single one bare for scripting.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back automatically with the podman service after a host reboot. start/stop manage them explicitly:
Auto-start on Boot
To have a Gitea instance started automatically on host boot, enable it:
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — the data dir was already chowned to uid 1000 at create time, so it needs no host privileges beyond podman itself. With --dsn, the external PostgreSQL must be up too: a managed instance with its own autostart starts earlier in the same pass; any other endpoint just needs to be reachable. See Autostart for the full mechanism, status and disabling.
Remove
Under rootless podman, --clean-data deletes the data dir through podman unshare rm — Gitea writes as the container’s git uid (1000), which maps to a subordinate uid the host user cannot rm directly. With --dsn the external database is never touched either way — drop it yourself if you really want the data gone.
Logs
Troubleshooting
installfails with a chown diagnostic — the image runs as uid 1000 and never fixes ownership of an existing mount, so pgcli chowns the host data dir before creating the container: throughpodman unshare chownunder rootless podman, or a direct recursivechownwhen pgcli itself runs as root on a rootful host. The manager shells out to the plainpodmanCLI, so this only fails outside those two cases — e.g. a non-rootpgexplicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manualsudo chown -R 1000:1000 <data-dir>alone does not clear it, since pgcli re-checks ownership on every install attempt.- The login password doesn’t work after a reinstall — the admin account is created, never reset. On an existing store (kept
/dataor--dsndatabase) usegitea admin user change-passwordinside the container, or wipe and start over (--clean-data, and drop the external DB by hand in dsn mode). - Clone URLs show
localhosteven though Gitea is on a server — a wildcard--listen 0.0.0.0collapses tolocalhostin displayed URLs because0.0.0.0is not connectable. Pass--listen <host-or-ip>(with--force) to make clone URLs real. ssh://clone says connection refused / wrong port — live cloning is off unless the instance was installed with--ssh-port(the s6 sshd is running but parked, so it is not a clone endpoint). And pgcli keepsSSH_PORT(what URLs show), the Gitea-sideSSH_LISTEN_PORT, and the bareSSH_LISTEN_PORT(what the s6 sshd actually binds) all equal — a hand-mismatched config breaks exactly this way.- Log line
Bind to port 22 … Address in use— the sshd’s park port has somehow reverted to 22 (an app.ini edit survived a boot, or bare env was stripped). A fresh install never hits this: pgcli always emits bareSSH_LISTEN_PORT=<pool port>for every instance, SSH on or off. git pushover HTTP asks for a password and rejects the web one — Gitea asks for the account’s credentials; if the account has 2FA on, HTTP push needs a personal access token or an SSH key instead of the password.- Port already allocated — the assigned ports are held in
pg.yamland skipped for later instances; free them withpg addon removefirst, or pick others with--port/--ssh-port.
Known limitations
- The image must be pinned to a version tag and upgraded by changing
--image(Gitea migrates its database forward on first boot of the newer image — there is no downgrade path). Rootful and rootless builds have mutually incompatible data layouts: pgcli pins the rootful build and an-rootlesstag will not read the existing/data(or the data written by another rootless deployment). - Plaintext HTTP: TLS termination and real hostnames are the nginx addon’s job — front Gitea with it in custom
--conf-filemode (the template’s 1 MiBclient_max_body_sizedefault breaks largergit push). Gitea rewrites the on-page clone URL fromX-Forwarded-Proto+Host, so behind nginx’s default proxy headers the copy button readshttps://<proxy>/…correctly. pgcli rendersROOT_URLashttp://<host>:<web-port>/and v1 exposes no knob to change that — the only visible effect so far is anything Gitea generates fromROOT_URLdirectly (rather than from the request) keeps the internal http origin. - v1 sets no OAuth, SMTP, webhook-tuning, or repository-storage (MinFS) options — everything beyond the table above is Gitea-side configuration pgcli deliberately leaves alone. Self-registration is closed; create users with
pg addon gitea user create. - On macOS the container runs inside the podman machine; it has been validated on Linux only.