Forgejo on the NAS: private git hosting for confidential repositories
What: a Forgejo instance on the TrueNAS host, reachable on the LAN and over the tailnet only. It hosts repositories that must not leave the house, starting with the Obsidian vault. GitHub stays the home for code and for this repository.
Why: the Obsidian vault holds confidential notes. Today it has no git remote at all, because no hosted service is trusted with it. The NAS already runs codified Docker workloads behind a Traefik proxy, with Vault secrets, house DNS, encrypted ZFS and Kopia backups. Forgejo fits that shape with no new infrastructure.
Status: design agreed with Sean on 2026-09-21. No implementation plan exists yet.
1. Scope
Section titled “1. Scope”This is not a migration. The instance holds a small number of private repositories for one person. The list below is the boundary, and the role’s defaults encode it.
- No mirrors from GitHub, in either direction.
- No Forgejo Actions runner.
- No organisation, no public repositories, no self-registration.
- No exposure through a Cloudflare tunnel, Cloudflare Access, or public DNS.
If any item on that list changes, it is a new design, not an amendment to this one.
2. Decisions
Section titled “2. Decisions”Sean made the three decisions below on 2026-09-21. The rest of this document follows from them.
| Decision | Choice |
|---|---|
| Reach | LAN and tailnet through the NAS Traefik proxy at git.nas.fzymgc.house |
| Git transport | HTTPS with a Forgejo access token, and SSH on port 2222 |
| Login | Keycloak OIDC as an additional sign-in, with a local admin account as break-glass |
The reasons, in the same order:
- The Obsidian Git plugin on iOS speaks HTTPS only, so the HTTPS path is required. The phone
must work away from home, so the tailnet path is required. The NAS Traefik proxy already
terminates TLS for
*.nas.fzymgc.housewith a Cloudflare-issued certificate, so one hostname serves both. - SSH is the better transport on the laptop, where an ed25519 key already exists.
- Keycloak is the house identity provider. The local admin account exists so that the notes stay reachable when Keycloak or the cluster is down.
3. Shape
Section titled “3. Shape”Forgejo runs as a TrueNAS custom app, the same shape as RustFS (ansible/roles/nas-rustfs). A
custom app is a Docker Compose definition that the middleware API owns. The role renders the
compose file and pushes it with midclt call app.update. Nothing on the appliance is edited by
hand.
| Concern | Choice | Reason |
|---|---|---|
| Image | codeberg.org/forgejo/forgejo:<major>-rootless, pinned, Renovate-tracked |
Public registry, so no app.registry credential is needed. The rootless image runs as a fixed uid and serves SSH on 2222 inside the container. |
| Database | SQLite | Upstream recommends it for low activity. One volume holds everything. |
| Data | New leaf dataset main/forgejo, ZFS-encrypted with a TrueNAS-managed hex key |
A custom app can bind only a leaf dataset. A hex key unlocks at boot. A passphrase key never does, and that kept paperless-docs locked after a reboot in July. |
| Backup | Add main/forgejo to nas_kopia_leaves and to the sandbox mounts |
Kopia encrypts on the client before B2 sees a byte. It reads live data, not a snapshot, so the SQLite file can be torn in one nightly copy. The main pool’s periodic snapshot tasks are recursive and give a consistent local copy every day. The bare repositories hold the notes, and git objects are immutable once written. |
| LFS | On, stored in the same dataset | Obsidian attachments are binary. |
| Networks | nas-internal and nas-public |
Traefik reaches the app over nas-internal. Docker silently drops a published port when every network is internal, so nas-public is required for SSH. |
| Resources | 1 cpu, 1 GiB memory | One user. Raise it when a measurement says to. |
The rustfs role reconciles interface aliases and issues an ICA1 certificate. Forgejo needs neither. Traefik holds the certificate, and the app publishes only one port.
4. Reach
Section titled “4. Reach”4.1 DNS
Section titled “4.1 DNS”tf/dns-records/hosts.yml gains one host record. git.nas.fzymgc.house points at the Traefik
VIP 192.168.20.201 and its ULA, the same as s3.nas.fzymgc.house. There is no public record.
4.2 HTTPS
Section titled “4.2 HTTPS”Traefik router labels on the compose service, copied from the rustfs pattern, bind
Host(git.nas.fzymgc.house) to the container’s port 3000 with tls.certresolver: cloudflare.
Forgejo’s ROOT_URL is https://git.nas.fzymgc.house/.
4.3 SSH
Section titled “4.3 SSH”The compose publishes host port 2222 to the container’s port 2222. The VIP 192.168.20.201 is an
alias on the same host interface, so git.nas.fzymgc.house:2222 reaches the container without a
Traefik TCP entrypoint. Forgejo’s SSH_DOMAIN is git.nas.fzymgc.house and SSH_PORT is 2222, so
the clone URLs the web UI shows are correct. Port 22 stays the appliance sshd.
4.4 Tailnet
Section titled “4.4 Tailnet”tailscale/policy.hujson already permits group:admin to network-main:*, which covers both the
VIP on 443 and the host on 2222. No policy change is needed for Sean. A non-admin member reaches
only nas-main:*, which is 192.168.20.200 and not the VIP. That is acceptable: the instance
has one user.
5. Login
Section titled “5. Login”5.1 Local admin
Section titled “5.1 Local admin”The role sets INSTALL_LOCK=true, DISABLE_REGISTRATION=true and REQUIRE_SIGNIN_VIEW=true.
With registration off, the first account cannot come from the web installer. The operator creates
it once, after the first converge, with the container’s own command line:
scripts/nas-adhoc.sh nas -b -m command \ -a 'docker exec forgejo forgejo admin user create --admin --username sean --email <email> --random-password'The password is set once from Vault after that command, or rotated with forgejo admin user change-password. This is a bootstrap step, like the out-of-band bucket creation in the rustfs
role. The role does not repeat it.
5.2 Keycloak
Section titled “5.2 Keycloak”tf/keycloak/forgejo.tf declares a confidential client forgejo with the standard flow, redirect
URI https://git.nas.fzymgc.house/user/oauth2/keycloak/callback and web origin
https://git.nas.fzymgc.house. The pattern is tf/keycloak/miniflux.tf. Terraform writes the
client id and secret to secret/fzymgc-house/infrastructure/nas/forgejo/oidc.
Forgejo has no declarative authentication source and no API for one. The operator adds it once
at https://git.nas.fzymgc.house/admin/auths/new: type OAuth2, provider OpenID Connect, name
keycloak, discovery URL from the realm, client id and secret from Vault. Auto-registration stays
off. Sean links the Keycloak identity to the local admin account from the account settings page.
After that, both sign-in paths land on the same account.
6. Secrets
Section titled “6. Secrets”| Vault path | Holds | Written by | Read by |
|---|---|---|---|
secret/fzymgc-house/infrastructure/nas/forgejo |
secret_key, internal_token, admin_password |
operator, once | the Ansible role |
secret/fzymgc-house/infrastructure/nas/forgejo/oidc |
oidc_client_id, oidc_client_secret |
tf/keycloak |
the operator, at auth-source creation |
secret/fzymgc-house/infrastructure/nas/forgejo-zfs-key |
the dataset hex key | the Ansible role | disaster recovery |
SECRET_KEY and INTERNAL_TOKEN reach the container as files under a read-only bind mount, with
FORGEJO__security__SECRET_KEY__FILE and FORGEJO__security__INTERNAL_TOKEN__FILE. The compose
string then names paths, not values, and app.config never echoes a secret.
The infrastructure-developer policy already reads secret/data/fzymgc-house/infrastructure/*,
so the role needs no new grant. The Keycloak workspace needs a grant on the /oidc subpath in
tf/vault/policy-keycloak.tf, in the same shape as cluster/miniflux/oidc. That grant must
apply before the Keycloak client, because the two live in different HCP workspaces.
7. Files that change
Section titled “7. Files that change”| Layer | Path | Change |
|---|---|---|
| Terraform | tf/vault/policy-keycloak.tf |
grant on infrastructure/nas/forgejo/oidc |
| Terraform | tf/keycloak/forgejo.tf |
new client and its Vault secret |
| Terraform | tf/dns-records/hosts.yml |
git_nas host record |
| Ansible | ansible/roles/nas-forgejo/ |
new role: dataset, secret files, compose, app.* reconcile |
| Ansible | ansible/nas-playbook.yml |
new play with tag nas-forgejo |
| Ansible | ansible/inventory/group_vars/all.yml |
main/forgejo in nas_kopia_leaves |
| Ansible | ansible/inventory/host_vars/nas.yml |
main/forgejo in nas_sandbox_mounts |
| Docs | docs/operations/nas.md |
tag map row, a Forgejo section with the two bootstrap steps |
| Docs | docs/reference/services.md |
one row |
Two pull requests. The first carries the Vault grant alone. The second carries everything else.
8. Acceptance
Section titled “8. Acceptance”Each criterion is a command. All run from the control node, once on the LAN and once on the tailnet away from home.
dig +short git.nas.fzymgc.house # 192.168.20.201curl -sS -o /dev/null -w '%{http_code}\n' https://git.nas.fzymgc.house/api/healthz # 200ssh -p 2222 git@git.nas.fzymgc.house # "Hi there, sean!"git clone https://git.nas.fzymgc.house/sean/obsidian-vault.git /tmp/av && rm -rf /tmp/avscripts/nas-adhoc.sh nas -b -m command -a 'zfs get -H -o value encryption,keyformat main/forgejo' # aes-256-gcm hexscripts/nas-playbook.sh --tags nas-forgejo --check --diff # no changes on a second runThe Kopia criterion is the existing freshness alert. After the first nightly run,
kopia snapshot list on nas-support lists /mnt/main/forgejo.
9. Not decided
Section titled “9. Not decided”- The Forgejo major version to pin. Read it from the registry with
crane lsat implementation time, not from a release page. - Whether the Obsidian vault repository needs a
.gitignorefor.obsidian/workspace*.json. That is a vault question, not a hosting question.