ahmadcloud.my.id_
← articles

Stop Clicking Through GitHub Settings: Manage Actions Secrets with Terraform

Learn how to manage GitHub Actions secrets and environment variables with Terraform using the integrations/github provider, a nested repo/environment schema, and an S3-compatible RustFS backend.

10 min readIaCby Ahmad Naufal

If you maintain more than three GitHub repositories with CI/CD, you've felt this pain: a new deploy key, a rotated API token, a new environment — and you sit there opening Settings → Environments → production → Add secret for every repo, every time. The web UI is fine for one repo. It does not scale to ten, and it absolutely does not scale to "we just rotated the SSH key, please update it everywhere by EOD."

So I moved every GitHub Actions secret and variable into Terraform — a single nested map drives the whole fleet, and terraform apply is the only command anyone needs to remember. This post is the honest version: the schema choices, the sensitive trap that ate two hours of my life, and the RustFS-as-S3 gotchas that don't show up in the official docs.

If you want to read the module while following along, the public repository is here:

Repository: terraform-github


When to use this (and when not to)#

Be honest with yourself before adopting Terraform here. The GitHub web UI is genuinely fine for one or two repos that rarely change. Terraform earns its keep when:

  • you have more than a handful of repos sharing the same secret pattern;
  • secrets rotate regularly (deploy keys, deploy tokens, signing keys);
  • you run multiple environments per repo (production, staging, development);
  • multiple people touch the secrets and you want review + audit through PRs;
  • you treat your CI/CD pipeline like infrastructure — reproducible, version-controlled, peer-reviewed.

The crossover is simple: the moment you copy-paste the same secret into three different repos, you've outgrown the UI.


Architecture#

The module reads existing repos, creates environments inside them, and writes env-scoped secrets and variables. State lives on RustFS.

Three resource types, one input map, zero hand-written per-repo blocks.


The schema: one map to rule them all#

If you give yourself a separate variable for secrets, another for variables, and another for environment lists, you'll spend the rest of the project keeping them aligned. So I collapsed everything into a single nested map:

hcl
variable "repo_environments" {
  description = "Existing repos and their deployment environments"
  type = map(map(object({
    secrets   = optional(map(string), {})
    variables = optional(map(string), {})
  })))

  sensitive = true
  default   = {}
}

A user-facing entry then looks like:

hcl
repo_environments = {
  "backend-api" = {
    "production" = {
      secrets   = { DEPLOY_TOKEN = "ghp_...", DATABASE_URL = "postgres://..." }
      variables = { ENVIRONMENT = "production", LOG_LEVEL = "info" }
    }
    "staging" = {
      secrets   = { DEPLOY_TOKEN = "ghp_..." }
      variables = { ENVIRONMENT = "staging" }
    }
  }

  "docs-site" = {
    "production" = {
      variables = { SITE_URL = "https://docs.example.com" }
    }
  }
}

The optional(map(string), {}) defaults mean an environment can have just variables, just secrets, or both. No required keys to remember.


Don't manage repos you didn't create#

The very first design decision was not to use github_repository as a managed resource. Reason: someone else (or some other process) already created those repos, and accidentally bringing them under Terraform management means a single terraform destroy could wipe production code.

Instead, repos are looked up as data sources:

hcl
data "github_repository" "managed" {
  for_each = local.repo_names
  name     = each.value
}

If a repo doesn't exist (typo, wrong owner, lacking token access), the data source returns null and the plan fails loudly — exactly what you want. Terraform never deletes anything it doesn't own.

The trade-off: this module won't create repos. If you want that too, add a parallel github_repository resource for new repos and keep this read-only lookup for adopted ones.


The sensitive trap that cost me two hours#

Here's the gotcha that's not in any tutorial: the moment you mark repo_environments as sensitive = true, everything derived from it becomes sensitive too — including the keys of the map.

And Terraform refuses to use sensitive values as for_each keys, because it would print them in the plan output. So:

hcl
# ❌ Fails: "for_each value cannot include values derived from sensitive output"
resource "github_repository_environment" "managed" {
  for_each = var.repo_environments
  ...
}

The fix: extract the keys (which are not secret — repo names and env names aren't credentials) with nonsensitive(), and look the secret value up inside the resource body where it can flow directly into the sensitive attribute:

hcl
locals {
  flat_env_secrets = nonsensitive({
    for triple in flatten([
      for repo, envs in var.repo_environments : [
        for env, cfg in envs : [
          for name, _ in cfg.secrets : { repo = repo, env = env, name = name }
        ]
      ]
    ]) : "${triple.repo}/${triple.env}/${triple.name}" => triple
  })
}

resource "github_actions_environment_secret" "managed" {
  for_each = local.flat_env_secrets

  repository  = data.github_repository.managed[each.value.repo].name
  environment = github_repository_environment.managed["${each.value.repo}/${each.value.env}"].environment
  secret_name = each.value.name

  # secret value is looked up INSIDE the resource — it stays sensitive
  value = var.repo_environments[each.value.repo][each.value.env].secrets[each.value.name]
}

The keys (repo, env, name) are metadata. The value is the secret. They travel separately. This pattern is the only way to combine for_each with a sensitive map, and it's the central design of this whole module.


value, not plaintext_value#

If you copy a snippet from a 2022 blog post you'll see this:

hcl
plaintext_value = "..."   # deprecated in integrations/github v6.x

It still kind of works, but it'll print a deprecation warning every plan and will eventually break. Use value instead — same semantics, properly marked sensitive:

hcl
value = var.repo_environments[...].secrets[...]

This applies to both github_actions_secret and github_actions_environment_secret.


RustFS as the state backend: three flags you can't skip#

I host state on RustFS (self-hosted S3-compatible storage on my Proxmox cluster). Three flags in providers.tf are non-negotiable, and I learned each of them the hard way:

hcl
backend "s3" {
  bucket = "terraform-state"
  key    = "github-automation/terraform.tfstate"
  region = "us-east-1"

  use_path_style              = true   # RustFS doesn't do virtual-hosted style URLs
  skip_credentials_validation = true   # AWS STS doesn't exist on RustFS
  skip_metadata_api_check     = true   # there's no EC2 IMDS
  skip_region_validation      = true   # 'us-east-1' is just a dummy here
  skip_requesting_account_id  = true   # no IAM, no account ID
  skip_s3_checksum            = true   # ← the killer one, explained below
  encrypt                     = false
}

The checksum gotcha#

The AWS SDK v2 (which Terraform's s3 backend uses since 1.5+) started sending checksum trailers on uploads. RustFS rejects them with a 400 or signature mismatch. The flag skip_s3_checksum = true disables this. Without it, every state save fails.

If you're on Terraform 1.11.2 or newer, that flag alone isn't enough — the SDK ignores it for PutObject. You also need two env vars set persistently:

fish
set -Ux AWS_REQUEST_CHECKSUM_CALCULATION when_required
set -Ux AWS_RESPONSE_CHECKSUM_VALIDATION when_required

(I learned this one when state writes silently started failing with XAmzContentSHA256Mismatch after a Terraform upgrade. Keep both belts on.)

Backend config can't be variables#

A subtle but important constraint: the backend "s3" block runs before Terraform even reads variables.tf or terraform.tfvars. So you can't put the endpoint URL, access key, or secret key in a variable.

The clean solution is backend.hcl (gitignored, never committed):

hcl
# backend.hcl
endpoints = {
  s3 = "https://rustfs.example.com"
}

access_key = "RUSTFS_ACCESS_KEY"
secret_key = "RUSTFS_SECRET_KEY"

Then init with:

fish
terraform init -backend-config=backend.hcl

The constant flags (use_path_style, skip_*) stay in providers.tf. The secret-bearing values live in backend.hcl. Best of both worlds.


Debugging SignatureDoesNotMatch#

Of all the errors I hit, this is the one that wasted the most time:

text
Error: Failed to get existing workspaces: operation error S3: ListObjectsV2,
api error SignatureDoesNotMatch: The request signature we calculated does not
match the signature you provided. Check your key and signing method.

SignatureDoesNotMatch means the request reached RustFS, but the request signature didn't match. The signature includes the region, the keys, and the request body — so any drift in those will fail. The actual cause is almost always one of:

  1. Wrong credentials — copy-paste added a trailing newline or quote. echo "[$AWS_ACCESS_KEY_ID]" to spot whitespace.
  2. Wrong region — RustFS doesn't care what region you pick, but the signature does. Pick something normal like us-east-1 and stay there.
  3. Checksum trailer — see the previous section.

The error message is the same for all three, so debug in that order.


Adopting environments that already exist#

If production already exists on a repo (because someone clicked through and set it up before), terraform apply will adopt it — but reset any custom settings (required reviewers, wait timers, branch policies) to defaults, because Terraform doesn't see them.

To preserve existing settings, import first:

fish
terraform import 'github_repository_environment.managed["backend-api/production"]' backend-api:production

The import ID format is <repo>:<env>. After import, run terraform plan and look at the diff: anything Terraform wants to change is what your config doesn't yet declare. Either add the missing attributes (recommended) or remove the environment from Terraform if you don't want to manage it.


Cleaning up: terraform state rm#

When you stop managing a repo's environments — say it got transferred to another org, or it's being archived — there are two correct approaches:

Option A: Stop managing, keep them on GitHub. Remove from state without destroying:

fish
terraform state rm \
  'data.github_repository.managed["backend-api"]' \
  'github_repository_environment.managed["backend-api/production"]' \
  'github_actions_environment_secret.managed["backend-api/production/DEPLOY_TOKEN"]'

Then also remove the repo from terraform.tfvars, otherwise the next plan re-adopts it.

Option B: Actually delete them. Remove from terraform.tfvars first, then terraform apply — Terraform will destroy the environments and their secrets on GitHub.

I prefer Option A almost always: the act of removing from terraform.tfvars should never be the trigger for production deletion. Make destruction a deliberate two-step action.


What the module produces#

Three useful outputs that downstream automation can consume:

OutputDescription
repository_full_namesMap of repo name → owner/repo
repository_urlsMap of repo name → HTML URL
managed_environmentsSorted list of repo/env pairs created by Terraform

managed_environments is the one I use most — it's a flat audit list of every deployment environment under Terraform's control, which I dump into our internal docs after every apply.


What this module does not do (deliberately)#

  • Create repositories. Use gh repo create or a separate github_repository resource. Keeping creation and configuration separate means you can adopt existing repos without risk.
  • Repository-level secrets. Everything is environment-scoped because that's how production-grade CI/CD should be structured. If you really need a repo-level secret, add a github_actions_secret resource — but think first about whether it should be env-scoped.
  • Branch protection or environment protection rules. Add them explicitly if you need required reviewers, wait timers, or branch-policy restrictions. Default behavior gives you a permissive environment that anyone with write can deploy to — fine for staging, often not fine for production.
  • Cross-organization repos. A single module instance assumes one github_owner. If you have repos under both your personal account and an org, run two Terraform workspaces.

These omissions aren't laziness — they're explicit trade-offs that keep the blast radius small.


Bringing it home#

The thing I appreciate most about this setup, four months in, isn't the automation — it's the review trail. Every secret rotation, every new environment, every variable change shows up as a pull request that someone else approves. Nobody fat-fingers a deploy token into prod anymore. Onboarding a new microservice is "add a block to terraform.tfvars and open a PR" — there's no Confluence runbook to update.

The Terraform code lives in a private repo behind RustFS-encrypted state, and the only credential anyone needs in their shell is a short-lived GitHub PAT plus a RustFS access key from a sealed envelope. The blast radius of a compromised laptop is "they can read the encrypted state file" — not "they can rotate every deploy key in the org".

If you've been clicking through GitHub settings for too long, this is the pattern. The provider is integrations/github ~> 6.0. The flags you need are above. The gotchas are documented. Go.

Stop Clicking Through GitHub Settings: Manage Actions Secrets with Terraform — ahmadcloud