- Python 100%
| nginx | ||
| systemd | ||
| tests | ||
| trara_infra | ||
| .env.example | ||
| .gitignore | ||
| compose.restore.yaml | ||
| compose.yaml | ||
| LICENSE | ||
| PULL_BACKUPS.md | ||
| README.md | ||
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:
- Forgejo v15 Docker installation
- Forgejo v15 configuration reference
- Forgejo v15 migration/path compatibility
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:
- 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. - An authenticated SMTP host, port, TLS protocol, username, sender, private password file and a recipient address. This project supplies no SMTP service.
- 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.
- 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.
- 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.