The problem
You have multiple GitHub repositories. Each repository has environments (production, staging, dev). Each environment has secrets (AWS keys, API tokens) and variables (API URLs, feature flags). Managing all of this manually through the GitHub UI is slow, error-prone, and hard to track.
The idea
Use OpenTofu (open-source Terraform alternative) to manage everything as code:
- Repository settings (visibility, merge options, features)
- GitHub Actions environments
- Environment secrets and variables
- Repository-level secrets
Non-sensitive configuration lives in a YAML file. Sensitive values are encrypted with SOPS and decrypted at runtime using a key stored in 1Password. Nothing sensitive is ever stored unencrypted on a disk.
Project structure
my-infra/├── environments.yaml # Non-sensitive config (URLs, feature flags)├── secrets.tfvars # SOPS-encrypted secrets (committed to git)├── .sops.yaml # SOPS encryption config├── main.tf # Root module - wires everything together├── variables.tf # Sensitive variable declarations├── providers.tf # GitHub provider config (multi-org)├── versions.tf # OpenTofu version constraints├── outputs.tf # Output values├── Makefile # Automation commands└── modules/ ├── github-repo/ # Manages repository settings + repo-level secrets │ ├── main.tf │ ├── variables.tf │ └── outputs.tf └── github-environments/ # Manages environments + per-env secrets/vars ├── main.tf ├── variables.tf └── outputs.tfHow it works
1. Define non-sensitive config in YAML
environments.yaml holds all non-sensitive configuration. This file is straightforward to read and edit, even for people who do not know HCL (Terraform’s language).
repositories: my-app: organization: my-org description: "My web application" visibility: private has_issues: true has_wiki: false allow_merge_commit: true allow_squash_merge: true allow_rebase_merge: true delete_branch_on_merge: false
environments: production: variables: VITE_API_HOST: "https://api.example.com" VITE_ASSETS_URL: "https://cdn.example.com" staging: variables: VITE_API_HOST: "https://api-staging.example.com" VITE_ASSETS_URL: "https://cdn-staging.example.com" dev: variables: VITE_API_HOST: "https://api-dev.example.com" VITE_ASSETS_URL: "https://cdn-dev.example.com"2. Define sensitive variables in HCL
variables.tf declares all sensitive inputs. OpenTofu marks them as sensitive so they never appear in logs.
variable "github_token" { description = "GitHub fine-grained personal access token" type = string sensitive = true}
variable "aws_access_key_id" { description = "AWS IAM access key ID" type = string sensitive = true}
variable "aws_secret_access_key" { description = "AWS IAM secret access key" type = string sensitive = true}
variable "npm_token" { description = "npm read-only token for private packages" type = string sensitive = true}
variable "environment_secrets" { description = "Per-environment secrets" type = map(object({ aws_bucket_name = string aws_distribution_id = string })) sensitive = true}3. Store secrets encrypted with SOPS
secrets.tfvars contains the actual secret values, but they are encrypted. You commit this file to git safely.
Configure SOPS to use age encryption in .sops.yaml:
creation_rules: - path_regex: secrets\.tfvars$ age: "age1abc123..." # Your age public keyThe encrypted file looks like this (you never edit it directly):
github_token = "ENC[AES256_GCM,data:abc123...,type:str]"aws_access_key_id = "ENC[AES256_GCM,data:def456...,type:str]"4. Store the decryption key in 1Password
The age private key is stored in 1Password. The Makefile fetches it automatically when you run commands:
SOPS_AGE_KEY = $(shell op read "op://MyVault/OpenTofu SOPS/Private Key")
.PHONY: planplan: SOPS_AGE_KEY=$(SOPS_AGE_KEY) sops -d secrets.tfvars > /tmp/secrets.tfvars tofu plan -var-file=/tmp/secrets.tfvars rm -f /tmp/secrets.tfvars
.PHONY: applyapply: SOPS_AGE_KEY=$(SOPS_AGE_KEY) sops -d secrets.tfvars > /tmp/secrets.tfvars tofu apply -var-file=/tmp/secrets.tfvars rm -f /tmp/secrets.tfvars
.PHONY: edit-secretsedit-secrets: SOPS_AGE_KEY=$(SOPS_AGE_KEY) sops secrets.tfvars
.PHONY: initinit: tofu init5. Wire everything in main.tf
The root module reads the YAML config and passes it to modules along with secrets:
locals { config = yamldecode(file("${path.module}/environments.yaml"))}
module "my_app_repo" { source = "./modules/github-repo"
name = "my-app" description = local.config.repositories.my-app.description visibility = local.config.repositories.my-app.visibility
secrets = { NPM_TOKEN = var.npm_token }
providers = { github = github.my_org }}
module "my_app_environments" { source = "./modules/github-environments"
repository = "my-app" environments = local.config.repositories.my-app.environments
environment_secrets = { production = { AWS_ACCESS_KEY_ID = var.aws_access_key_id AWS_SECRET_ACCESS_KEY = var.aws_secret_access_key AWS_BUCKET_NAME = var.environment_secrets["production"].aws_bucket_name AWS_DISTRIBUTION_ID = var.environment_secrets["production"].aws_distribution_id } staging = { AWS_ACCESS_KEY_ID = var.aws_access_key_id AWS_SECRET_ACCESS_KEY = var.aws_secret_access_key AWS_BUCKET_NAME = var.environment_secrets["staging"].aws_bucket_name AWS_DISTRIBUTION_ID = var.environment_secrets["staging"].aws_distribution_id } dev = { AWS_ACCESS_KEY_ID = var.aws_access_key_id AWS_SECRET_ACCESS_KEY = var.aws_secret_access_key AWS_BUCKET_NAME = var.environment_secrets["dev"].aws_bucket_name AWS_DISTRIBUTION_ID = var.environment_secrets["dev"].aws_distribution_id } }
providers = { github = github.my_org }}6. Multi-Organization support
If you manage repos across multiple GitHub organizations, use provider aliases:
provider "github" { alias = "my_org" token = var.github_token_my_org owner = "my-org"}
provider "github" { alias = "other_org" token = var.github_token_other_org owner = "other-org"}Then pass the right provider to each module:
module "app_in_other_org" { source = "./modules/github-repo" # ... providers = { github = github.other_org }}The modules
github-repo module
Manages repository settings and repo-level secrets:
resource "github_repository" "this" { name = var.name description = var.description visibility = var.visibility
has_issues = var.has_issues has_wiki = var.has_wiki
allow_merge_commit = var.allow_merge_commit allow_squash_merge = var.allow_squash_merge allow_rebase_merge = var.allow_rebase_merge}
resource "github_actions_secret" "this" { for_each = var.secrets
repository = github_repository.this.name secret_name = each.key plaintext_value = each.value}github-environments module
Manages GitHub Actions environments with their secrets and variables:
resource "github_repository_environment" "this" { for_each = var.environments
repository = var.repository environment = each.key}
locals { env_secrets = merge([ for env_name, secrets in var.environment_secrets : { for secret_name, secret_value in secrets : "${env_name}/${secret_name}" => { environment = env_name name = secret_name value = secret_value } } ]...)
env_variables = merge([ for env_name, env_config in var.environments : { for var_name, var_value in try(env_config.variables, {}) : "${env_name}/${var_name}" => { environment = env_name name = var_name value = var_value } } ]...)}
resource "github_actions_environment_secret" "this" { for_each = local.env_secrets
repository = var.repository environment = each.value.environment secret_name = each.value.name plaintext_value = each.value.value}
resource "github_actions_environment_variable" "this" { for_each = local.env_variables
repository = var.repository environment = each.value.environment variable_name = each.value.name value = each.value.value}The key pattern here is the flattened key structure ("env_name/secret_name"). OpenTofu’s for_each does not support nested loops directly, so we flatten the map first.
Daily operations
# First time setupmake init
# Preview what will changemake plan
# Apply changesmake apply
# Edit secrets (opens in editor, auto-encrypts on save)make edit-secretsAdding a new environment variable is as simple as editing environments.yaml:
# Just add a new lineproduction: variables: VITE_API_HOST: "https://api.example.com" VITE_NEW_FEATURE: "true" # <- add thisThen run make plan to preview and make apply to push it.
GitHub token setup
You need a fine-grained personal access token with these permissions:
| Permission | Access | Why |
|---|---|---|
| Administration | Read & Write | Repository settings |
| Environments | Read & Write | Create/update environments |
| Secrets | Read & Write | Manage secrets |
| Variables | Read & Write | Manage variables |
| Actions | Read-only | Required by GitHub API for environment operations |
The “Actions: Read-only” permission is not obvious but required. Without it, environment API calls return 403 errors.
Pros
- Single source of truth - all repository config in one place, version controlled
- Safe secret management - secrets are encrypted in git, decrypted only at runtime
- Easy for non-developers - YAML config is readable by anyone, no HCL knowledge needed for day-to-day changes
- Multi-org support - manage repositories across multiple GitHub organizations from one place
- No cloud dependency for encryption - age works offline, no AWS KMS or cloud service needed
- Audit trail - git history shows who changed what and when
- Reproducible - spin up identical configurations for new repositories quickly
- Local state - simple setup, no remote state backend to configure
Cons
- Local state file - not shared between team members, risky if lost (consider migrating to a remote state for teams)
- Manual execution - no CI/CD pipeline, someone must run
make applylocally - Token expiration - GitHub fine-grained PATs expire (max 1 year), requires periodic renewal
- Single point of failure - depends on 1Password access for the decryption key
- Learning curve - team members need to understand OpenTofu, SOPS, and age basics
- State drift - if someone changes settings in the GitHub UI, the state becomes out of sync (fix with
tofu planto detect andtofu applyto reconcile) - No plan approval workflow - no PR-based review before applying infrastructure changes
Requirements
- OpenTofu (or Terraform)
- SOPS
- age
- 1Password CLI (
op) - GitHub fine-grained personal access token
Quick start for a new project
- Install the tools listed above
- Generate an age key pair:
age-keygen -o key.txt - Store the private key in 1Password
- Put the public key in
.sops.yaml - Create
environments.yamlwith your repository and environment config - Create
secrets.tfvarswith your secrets, then encrypt:sops -e -i secrets.tfvars - Write your modules and
main.tf - Run
make init, thenmake plan, thenmake apply