Skip to content

Terraform provider

The official Terraform provider lets you declare your managed.dev fleet as code — sites, environments, custom domains, teams, and the scoped keys that drive your other automation. It’s a thin declarative adapter over the Go SDK: every call goes through the SDK, so idempotency keys, retries, and the pinned Forge-Version are handled for you. Because it speaks the standard plugin protocol, it works with both Terraform and OpenTofu.

Terraform manages resource shape — the sites, environments, and domains that exist. Imperative motion — restart, cache purge, backup, deploy, promote, rollback — is deliberately out of scope; drive that from CI or the mf CLI. There is no “deployment as a resource”.

The provider isn’t listed on the Terraform Registry yet. Build the plugin and point Terraform at the local binary with a dev override. First build it from the provider repo:

Build the plugin
make install # produces ./terraform-provider-forge

Then add a dev override to ~/.terraformrc that points at the built binary:

~/.terraformrc
provider_installation {
dev_overrides {
"managed-dev/forge" = "/absolute/path/to/terraform-provider-forge"
}
direct {}
}

The provider reads a Forge API key — an mfk_ token — from the FORGE_TOKEN environment variable, or the token provider argument (the argument wins when both are set). FORGE_TOKEN is the same variable the Go SDK and mf CLI read, so one credential works across every client surface.

Terminal window
export FORGE_TOKEN="mfk_live_…"

The provider takes two optional arguments:

Argument Description
token Forge API token (Sensitive). Falls back to FORGE_TOKEN.
endpoint Base URL of the API, including the /v1 prefix. Defaults to the production endpoint; override it for a staging fleet.

The provider models the resource graph as Terraform resources. State is keyed by the canonical UUID in every case — never the domain — so a domain that later moves doesn’t retarget a resource.

Type Name Manages
Resource forge_site A site/app. Async provision, teardown, and in-place PHP-version switch. domain/profile/team_id force replacement.
Resource forge_environment A staging or per-branch preview environment. Async create/delete; every input forces replacement.
Resource forge_domain A custom hostname with DNS-TXT verification; exports the record to publish.
Resource forge_api_key A scoped, reveal-once key for other automation. The secret lands in state.
Resource forge_team A team (rename in place). Needs an account-level key.
Data source forge_site Resolve an existing site by id or domain to its canonical id.
  • forge_sitedomain (required), profile (bedrock | vanilla | static, required, immutable), php_version (8.28.5, required unless the profile is static, mutable in place via the dedicated switch), plus optional region, team_id, git_repo, and git_branch. Exports id, status, slug, and runtime.
  • forge_environmentsite_id and type (staging | branch) required; branch required when type = branch; optional copy_uploads to seed from production. Exports id, status, subdomain, and env_slug.
  • forge_domainsite_id and hostname required; optional env_id (front a preview environment instead of production), verify (default true), and primary. Exports the dns_record_type / dns_record_host / dns_record_value you publish, plus verification_state.
  • forge_api_keyscopes required (may only be narrowed on update); optional name, expires_in_days (default 90), and a resource_type / resource_id pin. Exports id, prefix, last4, and the Sensitive secret.
  • forge_teamname (editable in place). Exports id, created_by, plan, and suspended.

This provisions a WordPress site, a staging environment, and a verified custom domain; mints a least-privilege key for a deploy bot; and resolves an existing site by domain — all in one apply.

main.tf
terraform {
required_providers {
forge = {
source = "managed-dev/forge"
}
}
}
provider "forge" {
# token read from FORGE_TOKEN when omitted;
# endpoint defaults to the production API root.
}
resource "forge_site" "blog" {
domain = "blog.example.com"
profile = "bedrock"
php_version = "8.3"
git_repo = "git@github.com:acme/blog.git"
git_branch = "main"
# Provisioning is an async job the provider waits on — bound the wait.
timeouts {
create = "30m"
delete = "15m"
}
}
resource "forge_environment" "staging" {
site_id = forge_site.blog.id
type = "staging"
}
# Attach a custom domain; publish the exported TXT record to pass verification.
resource "forge_domain" "www" {
site_id = forge_site.blog.id
hostname = "www.example.com"
primary = true
}
# A least-privilege key for a deploy bot, pinned to this one site.
resource "forge_api_key" "deploy_bot" {
name = "blog-deploy-bot"
scopes = ["sites:read", "deployments:write", "environments:write", "jobs:read"]
expires_in_days = 90
resource_type = "site"
resource_id = forge_site.blog.id
}
# Reference an EXISTING site by its domain; key other resources off the resolved
# .id, never the domain, so a moved domain cannot silently retarget them.
data "forge_site" "shop" {
domain = "acme-store.com"
}
output "verify_record" {
description = "Publish this TXT record to verify the custom domain."
value = {
type = forge_domain.www.dns_record_type
host = forge_domain.www.dns_record_host
value = forge_domain.www.dns_record_value
}
}
output "deploy_bot_key" {
value = forge_api_key.deploy_bot.secret
sensitive = true
}

Site and environment creates and deletes, and the site PHP-version switch, are async jobs. The provider writes the new resource id into state immediately, then blocks until the job reaches a terminal state — so a terraform apply doesn’t return until the site is actually ready, and a failed or timed-out job leaves a tracked (tainted) resource rather than an orphaned billing site. Bound each wait with the per-resource timeouts block.