Managing GitHub repositories and secrets with OpenTofu, SOPS, and 1Password

· Tech

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:

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

How 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 key

The 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: plan
plan:
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: apply
apply:
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-secrets
edit-secrets:
SOPS_AGE_KEY=$(SOPS_AGE_KEY) sops secrets.tfvars
.PHONY: init
init:
tofu init

5. 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:

providers.tf
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

Terminal window
# First time setup
make init
# Preview what will change
make plan
# Apply changes
make apply
# Edit secrets (opens in editor, auto-encrypts on save)
make edit-secrets

Adding a new environment variable is as simple as editing environments.yaml:

# Just add a new line
production:
variables:
VITE_API_HOST: "https://api.example.com"
VITE_NEW_FEATURE: "true" # <- add this

Then 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:

PermissionAccessWhy
AdministrationRead & WriteRepository settings
EnvironmentsRead & WriteCreate/update environments
SecretsRead & WriteManage secrets
VariablesRead & WriteManage variables
ActionsRead-onlyRequired 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

Cons

Requirements

Quick start for a new project

  1. Install the tools listed above
  2. Generate an age key pair: age-keygen -o key.txt
  3. Store the private key in 1Password
  4. Put the public key in .sops.yaml
  5. Create environments.yaml with your repository and environment config
  6. Create secrets.tfvars with your secrets, then encrypt: sops -e -i secrets.tfvars
  7. Write your modules and main.tf
  8. Run make init, then make plan, then make apply