Skip to main content

Configuration as code with OpenTofu

There is no dedicated fremforge OpenTofu provider yet. You can still keep your org in code today with two open-source community providers:

  • Forgejo objects (repositories, teams, branch protection, Actions secrets and variables) through Forgejo’s own REST API, with the svalabs/forgejo provider.
  • fremforge settings (IP allowlist, authentication policy, immutable releases, scan settings, branch-protection defaults) through the fremforge API, with the generic magodo/restful provider.

Both are run by you, on your machine or in your CI. fremforge does not host or operate them. A dedicated fremforge provider may follow; until it does, this page is the supported way, and it describes the edges honestly.

What we tested, and what we did not

Tested on 2026-10-02 with OpenTofu 1.12.6 against frem.sh (Forgejo v15.0.9), on an internal fremverk org:

CheckResult
svalabs/forgejo 1.6.1: tofu init, validate, data sources for an organisation, a repository and a teamWorks, values match the Forgejo API
svalabs/forgejo 1.6.1: import of an existing repository, team and branch-protection rule, then planWorks, the imported branch protection carries the real required checks
svalabs/forgejo 1.6.1: create, update or delete of a repository, team or branch protectionNot tested. We planned them but did not apply, because applying changes a live org. Treat writes as untested on fremforge
magodo/restful 0.25.2: the example module below, init, plan with import of all three settingsWorks
magodo/restful 0.25.2: apply of the immutable-releases resource, an out-of-band change, drift shown by plan, apply restoring itWorks (transcript below)
magodo/restful 0.25.2: apply of the IP allowlist and authentication policyNot run. Our test token had policy:read but not policy:write, so the API answered 403 insufficient-scope. The request shapes come from the API code, not from a live write

The Forgejo provider is maintained by a third party for Forgejo in general, not for fremforge. A future Forgejo release, or a fremforge-specific Forgejo change, can break it without notice from us.

Install the providers

terraform {
  required_version = ">= 1.8.0"

  required_providers {
    forgejo = {
      source  = "svalabs/forgejo"
      version = "1.6.1"
    }
    restful = {
      source  = "magodo/restful"
      version = "0.25.2"
    }
  }
}

Pin exact versions and commit .terraform.lock.hcl. The lock file records each provider’s checksums, so a later tofu init refuses a binary that changed.

Where the provider binaries come from

ProviderLicenceSourceBinary download
svalabs/forgejo 1.6.1MPL-2.0github.com/svalabs/terraform-provider-forgejoGitHub release assets
magodo/restful 0.25.2MPL-2.0github.com/magodo/terraform-provider-restfulGitHub release assets

tofu init looks the provider up in the OpenTofu registry (registry.opentofu.org), and the registry points the download at the project’s GitHub releases. So the first init fetches from GitHub, which is a US-hosted service. Nothing else in the path is: once installed, the providers talk only to frem.sh.

If you do not want CI to fetch from GitHub on every run, mirror the providers once and install from the mirror:

tofu providers mirror -platform=linux_amd64 ./tofu-providers
# ~/.tofurc (or the file named by TF_CLI_CONFIG_FILE)
provider_installation {
  filesystem_mirror {
    path    = "/path/to/tofu-providers"
    include = ["registry.opentofu.org/svalabs/forgejo", "registry.opentofu.org/magodo/restful"]
  }
}

The lock file still verifies the mirrored binaries against the recorded checksums.

Part 1: repositories, teams and branch protection

The Forgejo provider authenticates with a Forgejo personal access token from User settings → Applications (frem.sh/user/settings/applications), not with a fremforge ffp_ token.

variable "forgejo_token" {
  type      = string
  sensitive = true
}

provider "forgejo" {
  host      = "https://frem.sh"
  api_token = var.forgejo_token
}

data "forgejo_organization" "org" {
  name = "acme"
}

resource "forgejo_repository" "service" {
  owner          = data.forgejo_organization.org.name
  name           = "payments-service"
  private        = true
  default_branch = "main"
}

resource "forgejo_team" "reviewers" {
  organization_id = data.forgejo_organization.org.id
  name            = "payments-reviewers"
  permission      = "write"
  units_map = {
    "repo.code"  = "write"
    "repo.pulls" = "write"
  }
}

Branch protection: work with fremforge, not against it

fremforge manages part of every default-branch rule itself:

  • A new repository gets your org’s branch-protection defaults (Org admin → Repo defaults) when it is created, and an hourly sweep applies them to any repository that has no rule at all. A repository that already has a rule is left alone.
  • For every scanner your scan policy sets to enforce, fremforge adds its fremforge/<scanner> status context to the default branch’s required checks. It only adds; it never removes a check you added.

Two consequences for OpenTofu:

  1. Import the rule instead of creating it. On a new repository a rule for main may already exist by the time OpenTofu gets to it.

    import {
      to = forgejo_branch_protection.service_main
      id = "acme/payments-service/main"
    }
  2. List the fremforge contexts in status_check_contexts. If you leave them out, tofu plan shows them as drift, apply removes them, and fremforge adds them back. The full list is on Branch protection.

resource "forgejo_branch_protection" "service_main" {
  repository_id       = forgejo_repository.service.id
  branch_name         = "main"
  required_approvals  = 1
  enable_status_check = true
  status_check_contexts = [
    "fremforge/sast",
    "fremforge/dep-scan",
    "fremforge/license-scan",
    "fremforge/secret-scan",
    # ...every context from the branch-protection page, plus your own CI checks
  ]
}

To bring an existing estate under management, write import blocks for what is there and run tofu plan -generate-config-out=generated.tf. Expect to edit the generated file: in our test it wrote attribute combinations the provider then rejected (for example enable_prune on a repository that is not a mirror). Remove those and re-plan until it is clean.

Part 2: fremforge settings through the fremforge API

fremforge settings live behind the fremforge API at https://frem.sh/_app/api/v1. The OpenAPI spec describes every endpoint.

Most of these settings are singletons: every org has exactly one IP allowlist and one authentication policy, and they cannot be deleted. magodo/restful handles that shape. Create can be a PUT, the read path can differ from the write path, a read_selector can pick one object out of a larger response, and updates can be PATCH. The example below imports each setting rather than creating it, because the setting already exists.

The token

Use an org API token (ffp_...) from Org admin → API tokens (frem.sh/<org>/_admin/api-tokens). It is bound to one org, and it survives the person who minted it leaving. It expires after at most 90 days, so plan for rotation.

Resource in the exampleRead needsWrite needs
IP allowlistpolicy:readpolicy:write
Authentication policypolicy:readpolicy:write
Immutable releases and tagsfindings:readfindings:write

Mint two tokens if you can: a read-only one (policy:read, findings:read) for tofu plan on pull requests, and one with the write scopes for apply on main.

The example module

Three files. This is the module we ran on 2026-10-02; only the org name is changed.

versions.tf:

terraform {
  required_version = ">= 1.8.0"

  required_providers {
    restful = {
      source  = "magodo/restful"
      version = "0.25.2"
    }
  }
}

variables.tf:

variable "org" {
  description = "Your fremforge organisation slug, as in frem.sh/<org>."
  type        = string
}

variable "fremforge_token" {
  description = "Org API token (ffp_...) minted at /<org>/_admin/api-tokens."
  type        = string
  sensitive   = true
}

variable "office_cidrs" {
  description = "CIDR ranges allowed to reach the org. Must include the address tofu runs from."
  type        = list(string)
  default     = []
}

main.tf:

provider "restful" {
  base_url = "https://frem.sh/_app/api/v1"

  security = {
    http = {
      token = {
        token = var.fremforge_token
      }
    }
  }
}

locals {
  org_path = "/orgs/${var.org}"
}

# --- 1. IP allowlist -------------------------------------------------------
# PUT replaces the whole list; it is read back from GET /orgs/{org}/security.
resource "restful_resource" "ip_allowlist" {
  path          = "${local.org_path}/security/ip-policy"
  read_path     = "${local.org_path}/security"
  read_selector = "ip_allowlist"
  create_method = "PUT"
  update_method = "PUT"

  body = {
    cidrs                  = var.office_cidrs
    audit_only             = true
    allow_platform_runners = true
  }

  lifecycle {
    prevent_destroy = true
  }
}

import {
  to = restful_resource.ip_allowlist
  id = jsonencode({
    id            = "${local.org_path}/security"
    path          = "${local.org_path}/security/ip-policy"
    read_selector = "ip_allowlist"
    body = {
      cidrs                  = null
      audit_only             = null
      allow_platform_runners = null
    }
  })
}

# --- 2. Authentication policy ---------------------------------------------
# The policy always exists, so it is imported, then changed with PATCH.
resource "restful_resource" "auth_policy" {
  path                 = "${local.org_path}/auth-policy"
  create_method        = "PUT"
  update_method        = "PATCH"
  merge_patch_disabled = true

  body = {
    max_pat_lifetime_days  = 90
    ssh_disabled           = true
    allow_repo_deploy_keys = false
    require_signed_commits = false
  }

  lifecycle {
    prevent_destroy = true
  }
}

import {
  to = restful_resource.auth_policy
  id = jsonencode({
    id   = "${local.org_path}/auth-policy"
    path = "${local.org_path}/auth-policy"
    body = {
      max_pat_lifetime_days  = null
      ssh_disabled           = null
      allow_repo_deploy_keys = null
      require_signed_commits = null
    }
  })
}

# --- 3. Immutable releases and container tags ------------------------------
# WARNING: releases = true is one-way for every release published while it is on.
resource "restful_resource" "immutability" {
  path          = "${local.org_path}/code-security/immutability"
  create_method = "PUT"
  update_method = "PUT"

  body = {
    releases           = false
    allow_repo_opt_out = false
    oci_mode           = "off"
    oci_mutable_tags   = []
  }

  lifecycle {
    prevent_destroy = true
  }
}

import {
  to = restful_resource.immutability
  id = jsonencode({
    id   = "${local.org_path}/code-security/immutability"
    path = "${local.org_path}/code-security/immutability"
    body = {
      releases           = null
      allow_repo_opt_out = null
      oci_mode           = null
      oci_mutable_tags   = null
    }
  })
}

The body of each resource names only the fields you want OpenTofu to own. Fields you leave out are still shown under output in the plan, but OpenTofu never sends or compares them.

What each setting does when applied

IP allowlist (PUT /orgs/{org}/security/ip-policy). One list for every HTTPS surface; see IP allowlist.

  • The PUT replaces the list. Leaving cidrs out of a request clears it, which is why the example always sends it.
  • An enforcing list (audit_only = false) that does not contain the address the request comes from is refused with 400 invalid-allowlist, so OpenTofu cannot lock out the machine it runs on. Run with audit_only = true first, read the would-be denials, then switch it off. Add your CI runner’s egress addresses before you do.
  • scope is left out on purpose. A new list is always all; an org that still has a legacy web or web+git list keeps that scope while it edits the list, and asking to move to a legacy scope is refused.

Authentication policy (PATCH /orgs/{org}/auth-policy). The PATCH accepts any subset of the policy’s fields and leaves the rest alone; see Authentication policy. max_pat_lifetime_days takes null or one of the lifetimes the console offers; anything else is refused with 400 invalid-field. merge_patch_disabled = true makes the provider send the configured fields as plain JSON.

Immutable releases (PUT /orgs/{org}/code-security/immutability). Fields you leave out keep their value; see Immutable releases.

releases = true cannot be undone for the releases published while it is on. Setting it back to false later stops new releases being frozen, but it unfreezes nothing. Do not flip it from a tofu apply you have not read.

If immutable releases are not available for your org yet, both calls answer 404 not-enabled.

Run it

export TF_VAR_fremforge_token="ffp_..."   # from your secret store, never committed
tofu init
tofu plan -var org=acme
tofu apply -var org=acme

The first plan shows 3 to import and an in-place update for each resource. That update is only OpenTofu recording create_method, update_method and similar settings in state; the provider sends no request unless something in body differs. Read the body lines of the plan, not the summary line.

Drift detection

Someone changes a setting in the console, and the next tofu plan shows it. -detailed-exitcode turns that into something CI can act on: exit 0 means no changes, 2 means drift, 1 means an error.

This is the sequence we ran against the immutable-releases resource, with the change made directly through the API to stand in for a console edit:

$ tofu plan -detailed-exitcode            # exit 0
No changes. Your infrastructure matches the configuration.

$ curl -X PUT .../code-security/immutability -d '{"allow_repo_opt_out":true}'
{"releases":false,"allow_repo_opt_out":true,"oci_mode":"off",...}

$ tofu plan -detailed-exitcode            # exit 2
          ~ allow_repo_opt_out = true -> false
Plan: 0 to add, 1 to change, 0 to destroy.

$ tofu apply
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

$ tofu plan -detailed-exitcode            # exit 0
No changes. Your infrastructure matches the configuration.

A scheduled job that runs tofu plan -detailed-exitcode with the read-only token and alerts on exit 2 gives you a drift alarm without giving the job write access. Every change made through the API is also in your audit log under the token that made it, so the alert can be matched to the change.

Stop managing a setting

The fremforge settings cannot be deleted, so tofu destroy has nothing sensible to call. The example sets prevent_destroy so an accidental destroy fails at plan time. To hand a setting back to the console, replace its resource with a removed block. OpenTofu forgets it and leaves the live value as it is:

removed {
  from = restful_resource.immutability

  lifecycle {
    destroy = false
  }
}

We tested this: the plan reports 1 to forget and 0 to destroy.

What else the API covers

The same pattern works for other settings. Each row is a real endpoint in the spec; we have not written or tested a resource for these, so check each body shape against the OpenAPI spec before you rely on it.

SettingEndpointScopes (read / write)Fits the pattern?
Branch-protection defaults, incl. merge_queue (default, on, off) for the rules those defaults createGET/PUT /orgs/{org}/policy, body under branch_protectionpolicy:read / policy:writeYes; the PUT takes a partial body
SAST, dependency, image, secret and licence scan settingsPUT /orgs/{org}/code-security/{sast,deps,images,secrets,licenses}/settingsfindings:read / findings:writeYes
Security SLA policyPUT /orgs/{org}/security/sla-policy, read from GET /orgs/{org}/securitypolicy:read / policy:writeYes, with read_selector = "sla_policy"
Actions policy (Actions on protected branches only)PUT /orgs/{org}/security/actions-policy, read from GET /orgs/{org}/securitypolicy:read / policy:writePartly: you write only_protected_branches and read back actions_only_protected_branches, so the read needs a read_response_template
Artifact retentionGET/PUT /orgs/{org}/artifact-retentionpolicy:read / policy:writePartly: you write days and read back retention_days, so the read needs a read_response_template
Deployment environments/orgs/{org}/environmentsenvironments:read / environments:writeYes; ordinary create and delete
SIEM forwarding endpoints/orgs/{org}/siem/endpointspolicy:read / policy:writePartly: the HMAC secret is returned once at create and would land in your state file, and only enabled can be changed afterwards
SSO auth sources (OIDC, SAML)/orgs/{org}/ssosso:read / sso:writePartly: register and update the group-to-team map only

Not available through the API today, so not manageable from OpenTofu:

  • Native SSO enforcement (the per-org fresh re-authentication opt-in). No API endpoint reads or sets it.
  • Anything the OpenAPI spec does not list. An unknown path answers 501, which can also mean a typo in the path.

Limits of this approach

  • The generic provider knows nothing about fremforge. It cannot warn that releases = true is one-way, it cannot validate a body before apply, and every refusal arrives as the API’s error at apply time. The API’s own checks (the lock-out check on the IP allowlist, value checks on the authentication policy) still apply.
  • Import first. Singletons have to be imported before OpenTofu can manage them, and the import id has to name the same fields as body.
  • State holds the responses. The output attribute stores each read response in state. None of the three example settings carry a secret, but SIEM endpoints and SSO auth sources would. Use OpenTofu’s state encryption or a backend you trust.
  • The token is a long-lived secret. At most 90 days, and it can do whatever its scopes allow in your org. Keep policy:write out of anything that only needs to plan.
  • Forgejo writes are untested on fremforge. See the table at the top of this page.

A dedicated fremforge provider would remove the first two limits. If you depend on this, tell us at support@frem.sh which settings you need first.

Related