- HCL 48.9%
- Jinja 46.8%
- Shell 4.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Adds forgejo-restore.sh, deployed by the backup role, to restore the sqlite db and repositories from a restic snapshot in R2. Wires up never-tagged ansible tasks (backup-run, restore) to trigger a manual backup or restore outside the daily timer, and switches the deploy workflow's target selector from a comma-separated tags string to a checkbox per target so these new targets are easy to pick from the UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| ansible | ||
| terraform | ||
| .gitignore | ||
| README.md | ||
forgejo-infra
Terraform-managed infrastructure, plus Ansible-managed configuration, for a self-hosted Forgejo instance on Linode.
What this provisions
- A Linode 2GB shared instance (
g6-standard-1: 1 vCPU / 2GB RAM / 50GB disk) running Ubuntu 24.04 LTS - Forgejo installed as a native binary + systemd service (no Docker), using SQLite for the database
- A forgejo-mcp MCP server (native binary + systemd), exposed at
https://<fqdn>/mcp/, so AI assistants can manage repos/issues/PRs on this instance - nginx as a reverse proxy in front of Forgejo, with certbot for Let's Encrypt TLS
- A Cloud Firewall restricting inbound traffic to SSH, HTTP, HTTPS, and the git-SSH port (2222)
domain/hostnamealways build the FQDN used by nginx/app.ini/certbot; Linode DNS zone/record creation is opt-in (manage_dns_in_linode, defaultfalse) for when DNS is managed elsewhere (e.g. Cloudflare)
Repos and the SQLite DB live on the instance's boot disk under /var/lib/forgejo — the 50GB that comes with the 2GB plan covers this comfortably at small-to-medium scale, so there's no separate Block Storage volume to manage.
Layout
terraform/
versions.tf # provider requirements
variables.tf # all inputs
main.tf # instance, firewall, DNS
outputs.tf
terraform.tfvars.example # copy to terraform.tfvars and fill in
cloud-init/
forgejo.yaml.tftpl # first-boot bootstrap: forgejo binary, systemd, nginx, certbot
ansible/
ansible.cfg
site.yml # main playbook: forgejo, nginx, firewall roles
requirements.yml # collection deps (community.general, for ufw)
group_vars/all.yml # shared config: forgejo_version, fqdn, letsencrypt_email
inventory/hosts.yml.example # copy to hosts.yml, or generate from terraform outputs
scripts/generate-inventory.sh # regenerates hosts.yml from `terraform output`
roles/
forgejo/ # binary install (checksum-verified), app.ini, systemd unit
forgejo_runner/ # Actions runner, Docker-based job execution
forgejo_mcp/ # MCP server binary + systemd unit (see below)
nginx/ # reverse proxy config + certbot cert issuance/renewal
firewall/ # ufw rules mirroring the Linode Cloud Firewall
backup/ # sqlite snapshot + restic push to Cloudflare R2, on a systemd timer (see below)
Usage
1. Provision the box
cd terraform
cp terraform.tfvars.example terraform.tfvars
# edit terraform.tfvars: linode_token, root_pass, authorized_keys, domain, letsencrypt_email, etc.
terraform init
terraform plan
terraform apply
Generate a Linode API token at https://cloud.linode.com/profile/tokens. It needs:
| Scope | Access | Why |
|---|---|---|
| Linodes | Read/Write | create/manage the instance |
| Firewalls | Read/Write | create the Cloud Firewall and attach it |
| Domains | Read/Write | only if manage_dns_in_linode = true — creates the DNS zone + A record |
No other scopes are required — SSH keys are installed on the instance directly via authorized_keys at creation time, so the token doesn't need Account access.
This alone gets you a working instance — cloud-init installs the Forgejo binary, systemd unit, nginx, and requests a TLS cert on first boot, same as Ansible would. Ansible is what you reach for afterwards, for every change that isn't "create a new box."
2. Configure Ansible against it
cd ansible
ansible-galaxy collection install -r requirements.yml
./scripts/generate-inventory.sh # writes inventory/hosts.yml from terraform outputs
ansible-playbook site.yml
Running this right after terraform apply should report no changes (or close to it) — cloud-init already brought the box to the same state the playbook describes. That's the point: from here on, the playbook is the single source of truth for Forgejo's config, not manual edits on the box.
Day-to-day operations
- Bump the Forgejo version: edit
forgejo_versioninansible/group_vars/all.yml(check https://codeberg.org/forgejo/forgejo/releases for the latest), thenansible-playbook site.yml --tags forgejo. The binary is only re-downloaded if the checksum differs, and the service only restarts if the binary, config, or unit file actually changed. - Change
app.inisettings: editansible/roles/forgejo/templates/app.ini.j2, thenansible-playbook site.yml --tags forgejo. - Rotate/reissue TLS, or nginx config changes:
ansible-playbook site.yml --tags nginx. - Full run:
ansible-playbook site.yml— safe to run anytime, every task is idempotent.
MCP server
forgejo-mcp runs as its own systemd service on the box, bound to 127.0.0.1:8081 (see forgejo_mcp_http_port in group_vars/all.yml) and reverse-proxied by nginx at https://<fqdn>/mcp/.
It runs in multi-user mode — no Forgejo token is baked into the service or stored on the box. Each client authenticates with their own Forgejo access token (Settings → Applications → Access Tokens on the instance) via an Authorization: Bearer <token> header. Example client config:
{
"mcpServers": {
"forgejo": {
"type": "http",
"url": "https://<fqdn>/mcp/",
"headers": { "Authorization": "Bearer <your-forgejo-token>" }
}
}
}
- Disable it entirely by setting
forgejo_mcp_enabled: falseingroup_vars/all.yml(removes the systemd service and the nginx/mcp/location). - Bump the version: edit
forgejo_mcp_versioningroup_vars/all.yml, thenansible-playbook site.yml --tags forgejo_mcp. - It's only reachable over the TLS vhost, never the plain-HTTP bootstrap one — see the comment in
roles/nginx/templates/forgejo.conf.j2.
Backups
A systemd timer (forgejo-backup.timer, daily at 03:00 by default) takes a consistent SQLite snapshot (sqlite3 ... .backup, safe against the live DB) plus the repositories directory, and pushes both to a Cloudflare R2 bucket via restic — encrypted, deduplicated, with retention pruning (7 daily / 4 weekly / 6 monthly by default) baked into the same run.
Off by default (backup_enabled: false in group_vars/all.yml) until you supply R2 credentials:
- Create an R2 bucket in the Cloudflare dashboard, and an Account API token scoped to Object Read & Write on just that bucket. If you use the token's Client IP Address Filtering, add both the box's IPv4 and IPv6 addresses — it's dual-stack, and Cloudflare's endpoint resolves an AAAA record too, so outbound requests can leave over either depending on the client's Happy-Eyeballs preference. An IPv4-only filter fails with a generic
Access Denied(not an obviously IP-related error) the moment a request goes out over v6. Get the box's v6 address withcurl -s https://ifconfig.me(run with-6forced, or compare againstcurl -s https://ipv6.icanhazip.com) if it's not obvious fromterraform output. - Local runs:
cp ansible/inventory/secrets/backup.yml.example ansible/inventory/secrets/backup.yml, fill it in (gitignored, never committed), thenansible-playbook site.yml -e @inventory/secrets/backup.yml --tags backup. - CI-driven deploys: add
BACKUP_RESTIC_PASSWORD,BACKUP_R2_ACCOUNT_ID,BACKUP_R2_BUCKET,BACKUP_R2_ACCESS_KEY_ID,BACKUP_R2_SECRET_ACCESS_KEYas repo secrets (Settings → Actions → Secrets) —deploy.ymlturnsbackup_enabledon automatically onceBACKUP_RESTIC_PASSWORDis present.
Restic runs as the forgejo user (read access to its own data, nothing more) via EnvironmentFile=/etc/forgejo/backup-restic.env (mode 0640, owner forgejo).
Manual backup: trigger the timer's job on demand instead of waiting for 03:00, either directly on the box (systemctl start forgejo-backup.service) or via Ansible: ansible-playbook site.yml -e @inventory/secrets/backup.yml --tags backup-run.
Restore: /usr/local/bin/forgejo-restore.sh [snapshot-id] (root only, deployed by the backup role) stops forgejo/forgejo-runner, restores the given restic snapshot (default latest) into a scratch dir, swaps forgejo.db and repositories/ into place, and restarts the services — with a confirmation prompt since it overwrites live data. Run it directly on the box, or via Ansible: ansible-playbook site.yml -e @inventory/secrets/backup.yml --tags restore [-e restore_snapshot_id=<id>] (skips the prompt).
State
This is currently configured for local state (terraform.tfstate in the terraform/ directory, gitignored). That's fine solo, but it means state only exists on your machine — no collaboration, no locking, and you lose your remote-diffing ability if that machine is gone.
Recommended next step once this is a "real" service: move to a remote backend. Linode Object Storage is S3-compatible, so this works well and stays entirely in Linode's ecosystem:
terraform {
backend "s3" {
bucket = "your-tf-state-bucket"
key = "forgejo/terraform.tfstate"
region = "us-east-1" # required by provider, ignored by Linode
endpoint = "https://us-east-1.linodeobjects.com"
skip_credentials_validation = true
skip_region_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
}
}
Deployment approach: how changes actually ship
Terraform owns the box (compute, network, DNS). Ansible owns everything on it (the Forgejo binary, app.ini, the systemd unit, nginx, certs, firewall rules). Re-running terraform apply after editing the cloud-init template won't do anything, since cloud-init only runs on first boot — that's intentional, you don't want routine app updates risking a VM recreation (and with SQLite as a single file on the boot disk, recreating the VM means rebuilding from scratch).
-
Terraform — infrequent, structural changes: resizing the instance, opening a new firewall port, adding/changing a domain. Opens a PR against this repo like any other change.
-
Ansible — everything else: Forgejo version bumps,
app.initweaks, nginx/TLS changes. Runansible-playbook site.ymlby hand for now.Next planned step (not yet set up — flagged for later): wire
ansible-playbook site.ymlinto a Forgejo Actions workflow once the instance is up and running its own Actions, so config changes ship through CI on merge instead of a manual run from your laptop. Holding off on that until the base instance is deployed and stable. -
Backups — covered by the
backuprole (see Backups above): a systemd timer takes a SQLite snapshot plus the repo directory and pushes both to Cloudflare R2 viarestic, with retention pruning. Off until R2 credentials are supplied.
Security notes
ssh_allowed_ipsdefaults to open (0.0.0.0/0) so first apply doesn't lock you out before you know your IP — narrow it interraform.tfvarsonce the instance is up.root_passis required by the Linode API at creation time but SSH key auth is what's actually used day to day; consider disabling password auth in/etc/ssh/sshd_configpost-provision if you want to harden further.- The Forgejo systemd unit runs as an unprivileged
forgejosystem user withProtectSystem=strictand a narrowReadWritePaths, so a compromise of the Forgejo process itself doesn't get write access to the rest of the filesystem. - If
letsencrypt_emailis left unset, nginx serves plain HTTP only — set it (and a realdomain) to get TLS via certbot. - If DNS is managed outside Linode (e.g. Cloudflare), leave
manage_dns_in_linode = falseand point your provider's A record at theinstance_ipoutput yourself once the instance exists — certbot needs that record to already resolve before it can issue a cert, so do this before (or right after) the firstterraform applyand re-run the nginx role in Ansible (or the on-boxrequest-cert.shfrom cloud-init) once it's propagated. - certbot obtains certs via
certonly --nginxrather than its--nginxinstall/run mode, specifically so it never writes its own SSL block into the nginx config Ansible manages — the SSL block lives inroles/nginx/templates/forgejo.conf.j2and is toggled on once a cert exists. Don't runcertbot --nginx(withoutcertonly) by hand on the box — it'll edit the config file directly and the nextansible-playbookrun will clobber that edit.