Pinned Forgejo hosting and encrypted recovery tools.
Find a file
2026-10-18 12:56:11 +02:00
nginx Verify public hosting and encrypted off-host recovery 2026-10-18 12:56:11 +02:00
systemd Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
tests Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
trara_infra Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
.env.example Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
.gitignore Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
compose.restore.yaml Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
compose.yaml Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
LICENSE Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
PULL_BACKUPS.md Publish experimental TraraLink implementation and qualification tools 2026-10-18 12:52:16 +02:00
README.md Verify public hosting and encrypted off-host recovery 2026-10-18 12:56:11 +02:00

trara hosting infrastructure

SPDX-License-Identifier: MIT

Forgejo runs on muri.io behind nginx and HTTPS at code.trara.app; the project site is at link.trara.app. Administrator two-factor authentication is enrolled. Postmark delivery to the administrator's recipient mail server was verified on 2026-10-18; this does not establish a human inbox read. Encrypted pull backups use the separate dev host. See verification for the latest completed gates. The infrastructure suite currently has 84 tests. No existing nginx configuration, Docker project, website, SSH service or user project is modified by initialization. All runtime inputs, evidence and backups live in the ignored .local/ directory.

This is a dedicated Forgejo/Postgres Compose project, trara-forgejo, intended for Linux user/group 1000 on muri.io. Forgejo uses a rootless image/process; this does not claim that the host Docker daemon is rootless. User-namespace remapping requires separate review. The DB has its own internal network and named volume. There is no published DB/SSH port, runner, Docker socket, or user-provided Git hook support. Git initially uses HTTPS only. Forgejo publishes HTTP only at 127.0.0.1:3340; restore drills use 127.0.0.1:3341.

Both subdomains were configured through Vercel DNS to the serving IPv4 address. Activation requires live DNS/TLS checks, verified SMTP delivery, an encrypted offhost copy and an isolated populated restore. Operational evidence and secrets are private; the public repository ships no fabricated readiness records.

Requirements and image provenance

Use Python 3.11+, Docker Engine with Compose v2, Git, GnuPG and OpenSSH scp (prefer OpenSSH 9+ SFTP default). Dependencies must already be installed; the tools never install packages. The Python implementation and tests use only the standard library. Missing executables and failed commands fail closed. Subprocess output is captured and withheld, including errors that could expose app.ini or passwords. No shell/eval is used. Do not enable command tracing, print app.ini, run docker compose config into shared logs, or publish .local or container logs. Failure messages explain safe prerequisite failures; other exception details are suppressed because libraries can embed credentials.

The operator supplied these manifest pins as verified remotely on 2026-10-18, for linux/amd64 only. This implementation did not re-query the registry or inspect images:

Service Reference
Forgejo data.forgejo.org/forgejo/forgejo:15.0.10-rootless@sha256:2cec77b3181e6e52a4e4d110b2509ef48c0b03f279d520c51bf5731233a8cb18
Postgres postgres:16-alpine@sha256:1a66d744c1b459e13b05a8fca341da84cb63383e99ce262210efee5a319d4551

Do not remove platform: linux/amd64 or add another architecture without verifying its pins and runtime layout. validate-images checks local digest/architecture, actual UIDs, image path environment, Forgejo binary/path options, password/config readability, and the actual image entrypoint. It never pulls automatically. Explicit process flags select /var/lib/gitea, /var/lib/gitea/custom, and /etc/gitea/app.ini; startup and health checks must pass before these assumptions count as verified. It checks both GITEA_* and FORGEJO_* compatibility paths; never assume an environment prefix works just because another image version accepts it. Unexpected entrypoint config rewrites fail the hash check.

Upstream references consulted 2026-10-18:

Local review and tests

From this directory:

python3 -m unittest discover -s tests -v

These are real unit tests using temporary directories, synthetic configurations and tar archives, with network/process boundaries mocked. They test permissions, persistent-secret idempotency, configuration mismatch refusal, registration policy, DNS/evidence gates, archive traversal/links/size limits, restore boundaries, and backup restart-on-failure. Recovery regressions also cover loss of all original state files, independent ciphertext hash/provenance rejection, fixture identity/content/Git checks, empty recovery disqualification, and explicit moderation. GPG/Docker/Git/DB boundaries in these tests are mocked; synthetic tar bytes used at the mocked GPG boundary are not encrypted backups or hardware captures. They do not establish public deployment readiness. Real SMTP delivery, nonempty encrypted off-host backup and restore qualification remain required.

Loopback staging

Run on the intended Linux host as user 1000, from this directory. Commands below are instructions for the parent/operator; no remote access is performed automatically.

umask 077
python3 -m trara_infra init
TRARA_STATE="$PWD/.local/stage" docker compose --env-file .env.example -p trara-forgejo -f compose.yaml pull
python3 -m trara_infra validate-images
python3 -m trara_infra stage-up
python3 -m trara_infra preflight

init does not start containers. Rerunning it reuses secrets and verifies the exact configuration. A partial directory, altered app.ini, mismatched DB password, or changed requested SMTP inputs is an error; it is never silently repaired or rotated. An explicit configure-production transition is provided below. Keep a protected copy before manual recovery from a partial transition; never erase state just to get past a mismatch. All CLI operations take a nonblocking advisory lock under .local.

The parent .local, state/config/secrets directories are 0700, and app.ini/control files are 0600. The DB password is generated once and appears both in app.ini and the dedicated secrets/postgres-password file. The latter is 0444 inside two 0700 host directories and mounted read-only only into Postgres. This deliberately avoids Compose local-file secret UID remapping, which is not reliable: Debian-style Postgres UID 999, Alpine's actual UID, and Forgejo UID 1000 cannot all use an owner-only host file. The actual pinned image UID is observed by validate-images, not guessed. No secret is placed in YAML, command arguments, reports or environment interpolation. POSTGRES_PASSWORD_FILE is the only DB password environment setting. The persistent SECRET_KEY, INTERNAL_TOKEN and unpadded base64url 32-byte JWT_SECRET remain in app.ini and in encrypted backups; losing them can lose 2FA access.

Staging disables signup, mail and external registration. It uses HTTP cookies suitable only for loopback access. Do not install any nginx template for staging. The install wizard is locked; bootstrap an administrator through the operator-controlled Forgejo CLI or another upstream-supported private bootstrap procedure. Use a privately supplied password and keep it out of chat, shell history and process arguments. No admin-password automation is included. Enroll administrator 2FA before exposing the host. Local checks inspect process start time so edited config does not count as loaded until Forgejo restarts.

Branding initialization copies available SVGs from ../traralink/site/brand/, or its assets/logo/ fallback, into custom/public/assets/img/trara/. An available phasefold.svg also supplies the supported app logo slot. Source assets and default/user avatars are not modified. This uses upstream customization; there is no Forgejo fork.

Public prerequisites and activation

Required external inputs:

  1. Intended public IPv4/IPv6 addresses and DNS control for both subdomains. Supply every serving address with repeated --expected-ip; an unexpected AAAA also blocks activation.
  2. An authenticated SMTP host, port, TLS protocol, username, sender, private password file and a recipient address. This project supplies no SMTP service.
  3. Certificate issuance/renewal, existing nginx review, a populated static site destination and an operator who can install/reload nginx. The tools never edit live nginx or run Certbot.
  4. A separately administered offhost SSH/SFTP backup destination, verified host key, GnuPG recipient public-key fingerprint, and access to its protected decryption key for drills.
  5. Maintainer/admin account bootstrap, 2FA enrollment and official project permissions review.

Prepare .local/smtp.json with exactly these non-secret fields (substitute real values):

{"host":"smtp.example.invalid","port":587,"protocol":"smtp+starttls","user":"forge@example.invalid","from":"forge@example.invalid"}

smtps is also accepted (usually port 465). Put the password in .local/smtp-password, owned by the current user and 0600, using a private editor or secret-manager file export. Do not supply the password in chat, flags, YAML or environment variables. Input is read with ConfigParser(interpolation=None) and literal INI quoting; newlines/control characters are rejected. Configure/restart while registration stays closed:

python3 -m trara_infra configure-production --smtp-settings .local/smtp.json --smtp-password-file .local/smtp-password
python3 -m trara_infra production-up

For a new SMTP-configured state, init --production accepts the same two SMTP flags. It still keeps registration disabled. Reconfiguration preserves all persistent DB/app secrets.

After fixing DNS, the operator may review/install the ACME-only HTTP template, create /var/www/letsencrypt, and use the existing Certbot installation to obtain each certificate:

sudo certbot certonly --webroot -w /var/www/letsencrypt -d code.trara.app
sudo certbot certonly --webroot -w /var/www/letsencrypt -d link.trara.app

Do not install either TLS vhost until its Certbot live/<domain>/fullchain.pem and privkey.pem exist and pass certificate review. Remove/replace the ACME bootstrap vhost to avoid duplicate server names. Review conflicts with existing vhosts; install 00-trara-websocket.conf.template once in http{} and the code/link templates as dedicated vhosts, then run sudo nginx -t before an operator-controlled reload. These are templates, never automatically enabled files. The link template expects the site owner to populate /srv/traralink/site with the intended static site, including /dashboard/index.html; no source project is copied or altered here. Code proxy headers replace client forwarding/auth headers, use a fixed host, and support websocket upgrade. The trust boundary is the loopback listener and dedicated Docker network; do not attach unrelated workloads or expose that bridge to untrusted clients. Review the private bridge ranges in app.ini if this host uses a different network policy.

SMTP verification sends exactly one real email only when explicitly invoked:

python3 -m trara_infra smtp-test --to REAL_OPERATOR_INBOX

SMTP acceptance produces pending evidence, never success. After receiving the test, write its token into .local/smtp-receipt with 0600 permissions, then:

python3 -m trara_infra smtp-confirm --receipt-file .local/smtp-receipt

For Postmark SMTP using its server token, an alternative is an authenticated provider query for the exact test message:

python3 -m trara_infra smtp-confirm-postmark --message-id PROVIDER_MESSAGE_UUID

This requires a nonsandboxed Delivered event for the intended recipient and the matching unpredictable test receipt. Sent/Processed alone cannot qualify. Evidence explicitly records recipient-server acceptance and leaves receipt_verified false; this does not establish inbox placement or a human read. Both verification methods require the recent test and unchanged mail configuration.

The receipt must match a real accepted test from the last 24 hours and the current mailer configuration. It expires for readiness after seven days. Preflight itself never sends mail. Before launch, also exercise Forgejo's own SMTP test, new-account email confirmation, CAPTCHA and admin 2FA with controlled accounts; check that credentials with punctuation work in the actual app. Review DNS SPF/DKIM/DMARC and inbox delivery with the mail provider.

Complete an encrypted offhost backup and restore drill before activation. Then substitute the actual host addresses (the variable below is deliberately undefined until supplied):

python3 -m trara_infra preflight --public --expected-ip "$SERVER_IPV4"
python3 -m trara_infra activate --expected-ip "$SERVER_IPV4"

Repeat --expected-ip "$SERVER_IPV6" if applicable. Preflight/activation check current runtime health/pins/loopback bindings, DNS, CA-valid HTTPS/hostname/expiry (at least seven days), code health and link dashboard, plus scoped, nonstale SMTP/backup/restore evidence. Reports are canonical JSON; blocked checks return exit 2. Deployment reports only running-loopback. Only all verified prerequisites can produce ready; arbitrary marker existence is insufficient. activate opens signup and restarts Forgejo only after those checks. No publicity before checks pass and the operator completes the application policy review. If activation/restart fails, close registration before retrying:

python3 -m trara_infra close-registration

Readiness evidence is local operational evidence, not a cryptographically trusted remote attestation. Anyone controlling this host/user can forge it; protect .local and review it.

Community and official projects

Public configuration enables verified-email signup with image CAPTCHA, private email by default, enforced administrator 2FA, five repositories per user, and a default 1 GiB soft storage quota. Forks count toward the five-repository limit (ALLOW_FORK_WITHOUT_MAXIMUM_LIMIT=false). Regular users cannot create organizations. [moderation] ENABLED=true is explicit in both generated staging and production configurations; public readiness rejects an omitted or disabled setting. User Git hooks and Actions stay disabled. Existing generated configurations are never silently rewritten: the explicit configure-production transition above includes this setting while preserving persistent secrets, followed by production-up to load it. Monitor free disk space and quota reports; soft quotas are not filesystem hard limits.

Administrators create the official organization and give only maintainers ownership/write access. Review default team permissions, protected main/release branches and required PR reviews in Forgejo. Contributors use their own quota-limited forks and pull requests. Adjust the official organization's repository/storage allowance explicitly in admin policy; do not grant organization creation or a global quota exemption to ordinary contributors. Configuration defaults do not retroactively fix existing per-user overrides; audit those. CI is initially disabled, including fork PR execution. A later runner belongs on a separate, isolated host with its own trust/secret review; there is no runner deployment in this project.

Encrypted offhost backup

Prepare and select a recovery fixture manually

Before the qualifying backup, the operator must prepare a known repository on the owned trara-forgejo instance using its normal authenticated UI/Git workflow. Choose a repository with at least one reachable commit containing files, and a normal issue with a nonempty title and body. Retain the repository's lowercase owner/name, the full lowercase commit OID (not a branch name or abbreviated hash), and the issue number within that repository. No command here creates an account/repository/commit/issue or sends mail for this preparation. Keep staging signup and mail closed while using an already bootstrapped controlled account.

With that source instance running, explicitly select and read back the fixture:

python3 -m trara_infra select-recovery-fixture --repository "$FIXTURE_OWNER_AND_REPO" --commit "$FIXTURE_COMMIT_OID" --issue "$FIXTURE_ISSUE_NUMBER"

Use --state NAME before the command if the owned source uses another state directory. Selection first verifies source config and running instance pins/mounts. It resolves actual repository and issue database IDs, checks the issue belongs to that repository, and verifies the OID is a Git commit reachable from a ref with a nonempty tree. Its private evidence/recovery-fixture.json records repository ID/owner/name, exact commit OID, issue ID and number, and SHA-256 of canonical JSON containing the issue title, body and closed state. The CLI reports those identities/digests, not issue text or credentials.

Backup rechecks this selection while Forgejo is stopped, embeds the verified fixture in the encrypted manifest, and includes it and the manifest hash in backup evidence. A deleted, renamed or changed selected fixture fails backup rather than silently selecting new content. To change a fixture intentionally, select it again and take a new backup/restore pair. Without a selection, backup and restore remain usable, including for an empty instance; their recovery_fixture is null and restore's fixture_verified is false. These results cannot qualify for public readiness, regardless of health checks or repository/issue counts.

Prepare and independently verify/import the recipient public key; keep private recovery keys independently recoverable. Unlock the private key via your own protected GnuPG agent for drills (no passphrase argument/environment). Use a new unique remote filename each time; scp can overwrite a remote file. Establish/pin the destination's SSH host key in advance; batch transfer fails rather than prompting. Do not disable SSH host-key verification.

python3 -m trara_infra backup --recipient "$VERIFIED_GPG_FINGERPRINT" --offhost 'backup@BACKUP_HOST:/absolute/backups/UNIQUE-FILENAME.tar.gpg' --expected-ip "$SERVER_IPV4"

The command validates dependencies/recipient and that the destination resolves to a public host distinct from the supplied source-host addresses. It records whether Forgejo was running, stops only Forgejo, checks quiescence, takes a Postgres pg_dump --format=custom, records repository identity/Git refs and issue content, and archives data/, config/, custom/ and the dump/manifest. Raw Postgres volume data is not archived. Source symlinks/hardlinks and special files fail rather than dereference outside the state tree. The app restarts in finally if previously running, including a stop/dump/archive failure. Postgres stays running.

Encryption happens after restart. Plaintext exists only inside a newly created 0700 temporary directory under .local, removed by its context manager. This is ordinary file removal, not a promise of secure erasure on SSDs; use an encrypted host filesystem. Ciphertext stays 0600 in .local/backups. GnuPG verifies the selected full fingerprint is available; the explicit fingerprint is the operator's trust decision. Transfer is explicit scp, then the command retrieves the ciphertext into its temporary directory and compares SHA-256. A failed transfer/hash never produces verified offhost evidence. No other filesystem paths are deleted. Keep retention/redundancy scheduling under the operator's backup policy. Retain the successful report's original absolute source_state, source_project (trara-forgejo), exact ciphertext SHA-256 and archive identity independently of the source host, along with access to the GnuPG private key. The SHA-256 supplied during recovery must come from that independent record; hashing an untrusted replacement archive is not verification.

Isolated restore proof

restore-test is the ordinary drill for a surviving source deployment: it still requires and verifies the original config, control file and separate password file before proceeding, and validates archive provenance against that source. Use the exact ciphertext path and independently retained SHA-256 from a successful backup report. Create no target directory beforehand. Each attempt requires a fresh target and project:

python3 -m trara_infra restore-test --ciphertext "$PWD/.local/backups/EXACT-FILE.tar.gpg" --sha256 "$CIPHERTEXT_SHA256" --target restore-20261018a --project trara-restore-20261018a

The tool refuses existing target directories, existing Compose-labeled resources, and an existing database volume name. It checks ciphertext hash, decrypts through GnuPG, validates the entire archive before extraction (only regular files/directories, no absolute/traversal paths, links/devices, duplicate paths or file-parent conflicts; default 20 GiB/200,000 member limits), and verifies manifest/pins/config/dump provenance. No archive ownership/setuid bits are restored. Larger legitimate backups require a reviewed code change to extraction limits.

It restores into a separate named Postgres volume/project, rewrites only the isolated app configuration to loopback port 3341, disables mail/signup/webhooks/cron/CI, and blocks outbound container networking. It runs pg_restore --exit-on-error, compares repository identities, all recorded issue content and Git refs, runs Git fsck for each existing repository, then verifies health and the same snapshot after Forgejo startup. Empty instances are reported with zero counts and fixture_verified=false; a successful empty restore cannot satisfy the public gate. When a fixture is present, restore additionally verifies its repository database identity, actual reachable nonempty Git commit and issue ID/content digest against the restored Git files and database both before and after startup. A mismatch fails the drill and writes no success proof. Public readiness requires that verified fixture, matching the selected fixture in the exact offhost backup, plus matching ciphertext/manifest hashes and pins; old count-only restore reports do not qualify. After the drill both isolated services stop in finally. Target files/volume remain for parent review; there is no automated deletion or production DB overwrite. Never reuse the target/project for another test. To start the stopped drill manually for local review:

TRARA_STATE="$PWD/.local/restore-20261018a" TRARA_HTTP_PORT=3341 docker compose --env-file .env.example -p trara-restore-20261018a -f compose.yaml -f compose.restore.yaml up -d

Backup and restore readiness proof expire after two days and must refer to the same exact ciphertext and current persistent secrets. Repeat the pair regularly and after image/schema or secret changes. Failed operations leave clear nonzero JSON results, not fabricated proof.

Disaster recovery when the original source state is gone

Use the distinct recover-test entry point on the recovery host with this project's code, dependencies, exact pinned images already pulled, a protected working .local/ directory, the unchanged GnuPG archive imported beneath .local/, and your independently held GnuPG decryption key/agent. No original app.ini, control.json, password file, source directory, SMTP settings or other source-host file is required. Supply the original absolute state path as provenance text, even if the recovery checkout lives elsewhere; do not substitute the new host's path. Do not initialize a source instance to use this command.

python3 -m trara_infra recover-test --ciphertext "$PWD/.local/backups/EXACT-FILE.tar.gpg" --sha256 "$INDEPENDENT_CIPHERTEXT_SHA256" --source-state "$ORIGINAL_ABSOLUTE_SOURCE_STATE" --source-project trara-forgejo --target restore-disaster-20261018a --project trara-restore-disaster-20261018a

The expected hash is checked before Docker/GPG operations. Decrypted manifest source path and project must exactly equal the independent supplied provenance; pins, config hash, dump hash and persistent secrets are checked as in ordinary restore. Provenance is plain data: recovery never constructs a source Compose instance, reads source files, starts or stops source services, or writes source evidence, even if the original path still exists. Recovery reconstructs the isolated DB password from the encrypted config and preserves the archive bytes/identity/hash. It uses the same exclusive fresh restore-* target and fresh trara-restore-* project checks, isolated port/network policy and actual Git/DB/health verification as restore-test; it cannot replace a production state or database volume.

After both isolated services successfully stop, the proof is stored in the new target at .local/restore-disaster-20261018a/evidence/restore.json, with original provenance, ciphertext path/hash, manifest hash, fixture result and recovery_mode=recover-test. Ordinary restore records proof in both target and surviving source. A disaster proof is for recovery review; it does not automatically activate an instance or recreate expired SMTP/DNS/offhost-copy evidence. Files and isolated volume remain for operator review; production migration is a separate explicit operator action.