Skip to content

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.

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.

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:

  1. 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.house with a Cloudflare-issued certificate, so one hostname serves both.
  2. SSH is the better transport on the laptop, where an ed25519 key already exists.
  3. Keycloak is the house identity provider. The local admin account exists so that the notes stay reachable when Keycloak or the cluster is down.

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.

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.

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/.

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.

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.

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:

Terminal window
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.

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.

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.

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.

Each criterion is a command. All run from the control node, once on the LAN and once on the tailnet away from home.

Terminal window
dig +short git.nas.fzymgc.house # 192.168.20.201
curl -sS -o /dev/null -w '%{http_code}\n' https://git.nas.fzymgc.house/api/healthz # 200
ssh -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/av
scripts/nas-adhoc.sh nas -b -m command -a 'zfs get -H -o value encryption,keyformat main/forgejo' # aes-256-gcm hex
scripts/nas-playbook.sh --tags nas-forgejo --check --diff # no changes on a second run

The Kopia criterion is the existing freshness alert. After the first nightly run, kopia snapshot list on nas-support lists /mnt/main/forgejo.

  • The Forgejo major version to pin. Read it from the registry with crane ls at implementation time, not from a release page.
  • Whether the Obsidian vault repository needs a .gitignore for .obsidian/workspace*.json. That is a vault question, not a hosting question.