No description
  • HCL 48.9%
  • Jinja 46.8%
  • Shell 4.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jackson Oppenheim 740f7aa5db
All checks were successful
Shellcheck / shellcheck (push) Successful in 41s
Terraform Checks / validate (push) Successful in 58s
Ansible Checks / syntax-check (push) Successful in 5m19s
Add restore script and on-demand backup/restore ansible targets
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>
2026-09-16 10:02:50 -05:00
.forgejo/workflows Add restore script and on-demand backup/restore ansible targets 2026-09-16 10:02:50 -05:00
ansible Add restore script and on-demand backup/restore ansible targets 2026-09-16 10:02:50 -05:00
terraform init 2026-08-17 13:47:54 -05:00
.gitignore add backup infra 2026-08-18 22:01:26 -05:00
README.md Add restore script and on-demand backup/restore ansible targets 2026-09-16 10:02:50 -05:00

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/hostname always build the FQDN used by nginx/app.ini/certbot; Linode DNS zone/record creation is opt-in (manage_dns_in_linode, default false) 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_version in ansible/group_vars/all.yml (check https://codeberg.org/forgejo/forgejo/releases for the latest), then ansible-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.ini settings: edit ansible/roles/forgejo/templates/app.ini.j2, then ansible-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: false in group_vars/all.yml (removes the systemd service and the nginx /mcp/ location).
  • Bump the version: edit forgejo_mcp_version in group_vars/all.yml, then ansible-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:

  1. 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 with curl -s https://ifconfig.me (run with -6 forced, or compare against curl -s https://ipv6.icanhazip.com) if it's not obvious from terraform output.
  2. Local runs: cp ansible/inventory/secrets/backup.yml.example ansible/inventory/secrets/backup.yml, fill it in (gitignored, never committed), then ansible-playbook site.yml -e @inventory/secrets/backup.yml --tags backup.
  3. CI-driven deploys: add BACKUP_RESTIC_PASSWORD, BACKUP_R2_ACCOUNT_ID, BACKUP_R2_BUCKET, BACKUP_R2_ACCESS_KEY_ID, BACKUP_R2_SECRET_ACCESS_KEY as repo secrets (Settings → Actions → Secrets) — deploy.yml turns backup_enabled on automatically once BACKUP_RESTIC_PASSWORD is 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).

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

  2. Ansible — everything else: Forgejo version bumps, app.ini tweaks, nginx/TLS changes. Run ansible-playbook site.yml by hand for now.

    Next planned step (not yet set up — flagged for later): wire ansible-playbook site.yml into 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.

  3. Backups — covered by the backup role (see Backups above): a systemd timer takes a SQLite snapshot plus the repo directory and pushes both to Cloudflare R2 via restic, with retention pruning. Off until R2 credentials are supplied.

Security notes

  • ssh_allowed_ips defaults to open (0.0.0.0/0) so first apply doesn't lock you out before you know your IP — narrow it in terraform.tfvars once the instance is up.
  • root_pass is 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_config post-provision if you want to harden further.
  • The Forgejo systemd unit runs as an unprivileged forgejo system user with ProtectSystem=strict and a narrow ReadWritePaths, so a compromise of the Forgejo process itself doesn't get write access to the rest of the filesystem.
  • If letsencrypt_email is left unset, nginx serves plain HTTP only — set it (and a real domain) to get TLS via certbot.
  • If DNS is managed outside Linode (e.g. Cloudflare), leave manage_dns_in_linode = false and point your provider's A record at the instance_ip output 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 first terraform apply and re-run the nginx role in Ansible (or the on-box request-cert.sh from cloud-init) once it's propagated.
  • certbot obtains certs via certonly --nginx rather than its --nginx install/run mode, specifically so it never writes its own SSL block into the nginx config Ansible manages — the SSL block lives in roles/nginx/templates/forgejo.conf.j2 and is toggled on once a cert exists. Don't run certbot --nginx (without certonly) by hand on the box — it'll edit the config file directly and the next ansible-playbook run will clobber that edit.