One secure-by-default boundary for every GitHub Codespaces secret — repository, organization, and user scope — plus the selected-repository allow-list that pairs with
selected-visibility org secrets. Private-by-default org visibility,encrypted_valuepreferred over plaintext, sensitive inputs, and zero secret values in outputs. Built for integrations/github v6.x.
This is an aggregation module — a Codespaces secrets bundle. It has no single primary resource; instead it groups four independent, role-named for_each collections behind one boundary, each defaulting to {} so you enable only the scopes you use:
- 🔑 Repository secrets (
repository_secrets) — Codespaces secrets scoped to a single repository. - 🏢 Organization secrets (
organization_secrets) — org-wide Codespaces secrets withall/private/selectedvisibility (defaultprivate). - 🎯 Selected-repository allow-lists (
secret_repository_access) — the repository allow-list that aselected-visibility org secret reads from, managed as its own resource. - 👤 User secrets (
user_secrets) — Codespaces secrets scoped to the authenticated user, optionally granted to specific repositories.
💡 Why it matters: Codespaces secrets feed live developer environments with credentials. This module makes that surface declarative, least-privilege, and auditable — secrets are
sensitiveinputs, org secrets default toprivate, and only names and resource ids are ever emitted. It is the direct mirror ofterraform-github-dependabot, one scope up to include the user-level secret, so secrets-by-feature stay consistent across Dependabot / Actions / Codespaces.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn:linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee:buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
The keystone terraform-github-repository emits the repository name (id) and numeric id (repo_id) that this module consumes; the other secret-family siblings consume the same keystone. This module emits only names and ids — to audit/reporting tooling, never to another module's secret input.
flowchart LR
repo["🗝️ terraform-github-repository<br/>keystone"]
cs["🐙 terraform-github-codespaces<br/>(this module)"]
dep["terraform-github-dependabot"]
act["terraform-github-actions-repository"]
audit["📊 Audit / Reporting"]
repo -->|"id = repo name"| cs
repo -->|"repo_id (numeric)"| cs
repo -.-> dep
repo -.-> act
cs -->|"*_secret_names / *_secret_ids"| audit
style cs fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
🔌 Consumes: repository name (repo secrets) and numeric
repo_id(orgselectedallow-lists + user-secret access) fromterraform-github-repository. Emits: per-scope secret-name sets and id maps for audit/reporting. See the Cross-Module Contract.
There is no this resource. The diagram shows the four independent role-named collections; the only internal relationship is that a selected-visibility org secret pairs with the allow-list resource (own that allow-list in exactly one place).
flowchart TD
rs["var.repository_secrets<br/>map(object) — sensitive"] --> repo["github_codespaces_secret.repo<br/>for_each"]
os["var.organization_secrets<br/>map(object) — sensitive"] --> org["github_codespaces_organization_secret.org<br/>for_each"]
sra["var.secret_repository_access<br/>map(object)"] --> allow["github_codespaces_organization_secret_repositories.allow<br/>for_each"]
us["var.user_secrets<br/>map(object) — sensitive"] --> user["github_codespaces_user_secret.user<br/>for_each"]
org -.->|"visibility = selected<br/>allow-list owned in one place"| allow
style repo fill:#8957E5,color:#fff
style org fill:#8957E5,color:#fff
style allow fill:#8957E5,color:#fff
style user fill:#8957E5,color:#fff
Resource inventory
github_codespaces_secret.repo— repository-scoped Codespaces secrets, one perrepository_secretsentry.github_codespaces_organization_secret.org— organization-scoped Codespaces secrets withvisibility, one perorganization_secretsentry.github_codespaces_organization_secret_repositories.allow— the selected-repository allow-list for aselected-visibility org secret, one persecret_repository_accessentry.github_codespaces_user_secret.user— user-scoped Codespaces secrets, one peruser_secretsentry.
- Terraform
>= 1.12.0 - integrations/github
~> 6.0— validated against 6.12.1 - Never
hashicorp/github(deprecated). Migrate old state withterraform state replace-provider registry.terraform.io/hashicorp/github registry.terraform.io/integrations/github.
ℹ️ 6.x schema notes surfaced during authoring:
- No
key_idargument. All Codespaces secret resources acceptencrypted_value(Base64) andplaintext_valueonly — there is nokey_idfield. The provider resolves the appropriate Codespaces public key internally. Pre-encrypt against the right public key data source and pass just theencrypted_value.- No
visibilityon user secrets.github_codespaces_user_secrethas no visibility; it takes an optionalselected_repository_idsthat grants repositories access to the secret inside the user's Codespaces.- Only
created_at/updated_atare computed. These resources expose nonode_id,slug,repo_id, orhtml_url— the cross-module reference is the resourceid.
terraform-github-codespaces/
├── providers.tf # github provider requirement (~> 6.0); NO provider {} block
├── variables.tf # four role-named map(object) collections; secret-bearing ones sensitive
├── main.tf # repo / org / allow / user collections (no 'this'); nonsensitive(keys) pattern
├── outputs.tf # per-scope name sets + id maps; NEVER a secret value
├── README.md # this file
└── SCOPE.md # token scopes, prerequisites, consumes/emits contract, gotchas
The smallest useful call defines a single repository Codespaces secret. Every collection defaults to {}, so you supply only the scopes you need.
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"repository_secrets={
npm_token = {
repository ="loan-origination-svc"
secret_name ="NPM_TOKEN"
encrypted_value = var.npm_token_encrypted # Base64, pre-encrypted with the repo Codespaces public key
}
}
}ℹ️ The organization and authentication are the provider's
owner/GITHUB_OWNERand token/App — never module variables. Point the provider at the target org before applying.
Sourced directly from SCOPE.md.
| Input | Type | Source |
|---|---|---|
repository_secrets[].repository | string (repo name) | terraform-github-repository → id |
organization_secrets[].selected_repository_ids | list(number) | terraform-github-repository → repo_id |
secret_repository_access[].selected_repository_ids | list(number) | terraform-github-repository → repo_id |
user_secrets[].selected_repository_ids | list(number) | terraform-github-repository → repo_id |
| Output | Description | Consumed by |
|---|---|---|
repository_secret_names | Set of managed repo Codespaces secret names (values never emitted) | Audit / drift detection |
repository_secret_ids | Map: handle → github_codespaces_secret resource id | Reporting |
organization_secret_names | Set of managed org Codespaces secret names | Audit |
organization_secret_ids | Map: handle → github_codespaces_organization_secret resource id | Reporting |
user_secret_names | Set of managed user Codespaces secret names | Audit |
user_secret_ids | Map: handle → github_codespaces_user_secret resource id | Reporting |
secret_repository_access_ids | Map: handle → github_codespaces_organization_secret_repositories resource id | Reporting |
🔒 No secret value is ever output. Only names and resource ids are emitted.
Every collection is keyed by a stable caller handle (the map key) — never a list index. Examples use the new GitHub-org source and sibling terraform-github-* references.
1 · Minimal — one repository secret
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"repository_secrets={
npm_token = {
repository ="loan-origination-svc"
secret_name ="NPM_TOKEN"
plaintext_value = var.npm_token # provider encrypts on write
}
}
}💡 Supply exactly one of
plaintext_valueorencrypted_valueper secret.
2 · Organization secret — visibility = all
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"organization_secrets={
shared_registry = {
secret_name ="SHARED_REGISTRY_TOKEN"
visibility ="all"# every repo in the org, including public
plaintext_value = var.shared_registry_token
}
}
}
⚠️ allexposes the secret to every repository, including public ones. Preferprivateorselectedat a regulated institution.
3 · Organization secret — visibility = private (secure default)
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"organization_secrets={
artifactory = {
secret_name ="ARTIFACTORY_TOKEN"# visibility omitted → defaults to "private" (all private/internal repos)
encrypted_value = var.artifactory_token_encrypted
}
}
}4 · Organization secret — visibility = selected, allow-list managed separately
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"organization_secrets={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
visibility ="selected"
encrypted_value = var.payments_key_encrypted
# NOTE: do NOT set selected_repository_ids inline — owned by the allow-list below.
}
}
secret_repository_access={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
selected_repository_ids = [12345678, 23456789] # numeric repo ids
}
}
}🔒 Own each
selectedsecret's allow-list in exactly one place — here, or inline viaselected_repository_idson the secret (example 5), never both.
5 · Organization secret — visibility = selected, allow-list inline
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"organization_secrets={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
visibility ="selected"
encrypted_value = var.payments_key_encrypted
selected_repository_ids = [12345678, 23456789] # inline allow-list
}
}
# Do not also add a secret_repository_access entry for PAYMENTS_API_KEY.
}6 · User secret
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"user_secrets={
gh_pat = {
secret_name ="PERSONAL_GH_PAT"
plaintext_value = var.personal_gh_pat
}
}
}ℹ️ User secrets attach to the authenticated user (the provider identity), not the org. They need the
codespace:secretsclassic scope.
7 · User secret granted to specific repositories
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"user_secrets={
gh_pat = {
secret_name ="PERSONAL_GH_PAT"
encrypted_value = var.personal_gh_pat_encrypted
selected_repository_ids = [12345678] # repos that may read the secret in the user's Codespaces
}
}
}8 · encrypted_value everywhere (preferred over plaintext)
# Encrypt against the matching Codespaces public key BEFORE Terraform runs.data"github_codespaces_public_key""repo" {
repository="loan-origination-svc"
}
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"repository_secrets={
db_password = {
repository ="loan-origination-svc"
secret_name ="DB_PASSWORD"
encrypted_value = var.db_password_encrypted # Base64, encrypted with the repo public key
}
}
}🔒 Prefer
encrypted_value: the plaintext never enters your HCL. (Note: there is nokey_idargument — the provider resolves the public key internally; you supply only the Base64 ciphertext.) State always holds the value regardless — keep your backend encrypted.
9 · for_each at scale — many repo secrets from one map(object)
variable"repo_codespaces_secrets" {
type=map(object({
repository =string
secret_name =string
encrypted_value =string
}))
default={}
sensitive=true
}
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"repository_secrets=var.repo_codespaces_secrets
}
⚠️ Each secret is one API write plus a public-key fetch. Large maps can trip secondary rate limits — split very large applies.
10 · Mixed scopes in a single call
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"repository_secrets={
npm = { repository ="web-portal", secret_name ="NPM_TOKEN", encrypted_value = var.npm_enc }
}
organization_secrets={
artifactory = { secret_name ="ARTIFACTORY_TOKEN", visibility ="private", encrypted_value = var.arti_enc }
}
user_secrets={
gh_pat = { secret_name ="PERSONAL_GH_PAT", encrypted_value = var.pat_enc }
}
}11 · Repository secrets wired from terraform-github-repository (cross-module)
module"repository" {
source="git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"name="loan-origination-svc"visibility="private"
}
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"repository_secrets={
npm_token = {
repository = module.repository.id # repo NAME from the keystone
secret_name ="NPM_TOKEN"
encrypted_value = var.npm_token_encrypted
}
}
}12 · Selected org secret wired by numeric repo_id (cross-module)
module"repository" {
source="git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"name="payments-svc"visibility="private"
}
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"organization_secrets={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
visibility ="selected"
encrypted_value = var.payments_key_encrypted
}
}
secret_repository_access={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
selected_repository_ids = [module.repository.repo_id] # NUMERIC id from the keystone
}
}
}13 · 🔒 Secure / hardened variant — encrypted everywhere, selected, least repos
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"# Repo secrets: encrypted_value only, never plaintext in code.repository_secrets={
db = {
repository = module.repository.id
secret_name ="DB_PASSWORD"
encrypted_value = var.db_password_encrypted
}
}
# Org secrets: selected visibility, the narrowest possible allow-list.organization_secrets={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
visibility ="selected"
encrypted_value = var.payments_key_encrypted
}
}
secret_repository_access={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
selected_repository_ids = [module.repository.repo_id] # exactly the repos that need it
}
}
}🔒 Hardened posture:
encrypted_valueeverywhere, org secretsselected(neverall), allow-lists scoped to the minimum repositories, secrets aresensitiveinputs, no values in outputs.
14 · Consuming the outputs for audit / reporting
module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"#... collections...
}
output"managed_codespaces_secret_names" {
value={
repository = module.codespaces.repository_secret_names
organization = module.codespaces.organization_secret_names
user = module.codespaces.user_secret_names
}
}
# Assert the org never holds an unexpected Codespaces secret (drift detection).check"no_unexpected_org_secret" {
assert {
condition=!contains(module.codespaces.organization_secret_names, "LEGACY_TOKEN")
error_message="A retired Codespaces org secret (LEGACY_TOKEN) is still managed."
}
}ℹ️ Outputs are names and ids only — never values. Each map/set is empty (
{}/[]) when its scope is unused, nevernull.
15 · 🏗️ End-to-end composition (mandatory finale) — repository → codespaces, outputs → inputs
# 1) Keystone repository.module"repository" {
source="git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"name="loan-origination-svc"visibility="private"vulnerability_alerts=true
}
# 2) Codespaces secrets wired entirely from the keystone's outputs.module"codespaces" {
source="git::https://github.com/microsoftexpert/terraform-github-codespaces?ref=v1.0.0"# repo name → repository_secrets[].repositoryrepository_secrets={
npm_token = {
repository = module.repository.id
secret_name ="NPM_TOKEN"
encrypted_value = var.npm_token_encrypted
}
}
# org secret, selected visibilityorganization_secrets={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
visibility ="selected"
encrypted_value = var.payments_key_encrypted
}
}
# numeric repo_id → allow-listsecret_repository_access={
payments_key = {
secret_name ="PAYMENTS_API_KEY"
selected_repository_ids = [module.repository.repo_id]
}
}
}
output"codespaces_repo_secret_ids" {
value=module.codespaces.repository_secret_ids
}💡 The keystone emits
id(the repo name) andrepo_id(the numeric id). The name flows into repo-secret entries; the numeric id flows intoselectedallow-lists. Apply the repository before the secrets.
All four inputs are map(object(...)) keyed by a stable caller handle, each defaulting to {} (independently optional). The three secret-bearing collections are sensitive = true.
repository_secrets(sensitive) — repo-scoped secrets. Required per entry:repository,secret_name. Optional:plaintext_value,encrypted_value(supply one; prefer encrypted).organization_secrets(sensitive) — org-scoped secrets. Required:secret_name. Optional:visibility(defaultprivate; validatedall/private/selected),plaintext_value,encrypted_value,selected_repository_ids(inline allow-list, default[]).secret_repository_access(not sensitive) — allow-lists forselectedorg secrets. Required:secret_name,selected_repository_ids.user_secrets(sensitive) — user-scoped secrets. Required:secret_name. Optional:plaintext_value,encrypted_value,selected_repository_ids(repos granted access, default[]).
Full object schemas
variable"repository_secrets" {
type=map(object({
repository =string
secret_name =string
plaintext_value =optional(string)
encrypted_value =optional(string)
}))
default={}
sensitive=true
}
variable"organization_secrets" {
type=map(object({
secret_name =string
visibility =optional(string, "private") # all | private | selected
plaintext_value =optional(string)
encrypted_value =optional(string)
selected_repository_ids =optional(list(number), [])
}))
default={}
sensitive=true# validation: visibility ∈ {all, private, selected}
}
variable"secret_repository_access" {
type=map(object({
secret_name =string
selected_repository_ids =list(number)
}))
default={}
}
variable"user_secrets" {
type=map(object({
secret_name =string
plaintext_value =optional(string)
encrypted_value =optional(string)
selected_repository_ids =optional(list(number), [])
}))
default={}
sensitive=true
}| Output | Description |
|---|---|
repository_secret_ids | Map: handle → github_codespaces_secret resource id. Empty {} when unused. |
repository_secret_names | Set of managed repository Codespaces secret names. |
organization_secret_ids | Map: handle → github_codespaces_organization_secret resource id. |
organization_secret_names | Set of managed organization Codespaces secret names. |
secret_repository_access_ids | Map: handle → github_codespaces_organization_secret_repositories resource id. |
user_secret_ids | Map: handle → github_codespaces_user_secret resource id. |
user_secret_names | Set of managed user Codespaces secret names. |
🔒 No output is
sensitive— and none is a secret value. Only names and resource ids are emitted; structural fields are revealed withnonsensitive. Every map/set is empty (notnull) when its scope is unused.
- Aggregation shape — no
this. There is no single dominant resource; each scope is a peerfor_eachcollection named by role (repo,org,allow,user). This mirrorsterraform-github-dependabot(repo + org + allow) one scope up, addinggithub_codespaces_user_secret. sensitiveinputs +nonsensitive(keys).repository_secrets,organization_secrets, anduser_secretsaresensitive = truebecause they carry secret values. Terraform forbidsfor_eachover a sensitive value, somain.tfiteratesnonsensitive(toset(keys(...)))and indexes the map inside each resource. Non-secret structural fields (repository,secret_name,visibility) are revealed withnonsensitiveso plan output and outputs stay useful; the secret value flows straight through to the schema-sensitiveplaintext_value/encrypted_valuearguments.encrypted_valuepreferred — and there is nokey_id. Preferencrypted_value(pre-encrypted, Base64) overplaintext_valueso plaintext never enters HCL. These resources expose nokey_idargument — the provider resolves the Codespaces public key internally.⚠️ Either way the value lives in state — keep the backend encrypted.- Org visibility & the one-place allow-list rule.
organization_secrets[].visibilityisall/private/selected(defaultprivate, validated). Forselected, own the allow-list in exactly one place: inlineselected_repository_idson the secret or asecret_repository_accessentry — never both for the same secret, or they fight over the same allow-list. - Values are write-only; names/ids are the only outputs. The provider cannot read a secret's value back, so it cannot detect remote-value drift — changing a value in code re-writes it; a value changed only in the GitHub UI is invisible to Terraform. Secret names are tracked. On
import, the provider does not fetch the value (terraform import github_codespaces_secret.repo[\"handle\"] repo/SECRET_NAME; org/user import by secret name) — you may needignore_changeson the value as a workaround. - No tags, no timeouts. GitHub has no resource tagging concept and these resources expose no
timeoutsblock — there is no tags/timeouts tail (the key difference from the azurerm/aws libraries).
- 🔒 Private by default — org-secret
visibilitydefaults toprivate, neverall/public. Wider exposure is an explicit, reviewed opt-in. - 🗝️ Encrypt over plaintext —
encrypted_valueis the documented preference so secrets never sit in HCL;plaintext_valueis the fallback. - 🙈 Secrets are
sensitive— every secret-bearing input map issensitive = true; no secret value is ever emitted as an output. - 🎯 Least privilege —
selectedorg secrets scope to the minimum repositories; allow-lists are owned in one place to avoid accidental over-grant. - 🧩 Composable, auth-free — no
provider {}block, noowner/token/app_authvariables; the caller configures auth and the target org.integrations/githubonly — neverhashicorp/github. - 🧾 Auditable — emits exactly the names and ids audit/reporting tooling needs, and nothing more.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Always pin the module to a tag —?ref=v1.0.0— never a branch. Codespaces secrets feed live developer environments at a regulated institution; a movingrefis unacceptable.
Offline proof gate (no live org required):
terraform fmt -check # zero formatting differences
terraform validate # configuration is valid
tflint # core rules (no dedicated GitHub ruleset exists)✅ This module passes
terraform init -backend=false,terraform validate("Success! The configuration is valid."), andterraform fmt -check(zero diffs) againstintegrations/github6.12.1.
repository_secret_ids = {
"npm_token" = "loan-origination-svc:NPM_TOKEN"
}
repository_secret_names = toset([
"NPM_TOKEN",
])
organization_secret_ids = {
"payments_key" = "PAYMENTS_API_KEY"
}
organization_secret_names = toset([
"PAYMENTS_API_KEY",
])
secret_repository_access_ids = {
"payments_key" = "PAYMENTS_API_KEY"
}
user_secret_ids = {}
user_secret_names = toset([])
| Symptom | Cause | Resolution |
|---|---|---|
Invalid for_each argument / "sensitive value" on plan | A secret-bearing map was iterated directly | The module already uses for_each = nonsensitive(toset(keys(...))); if you adapt it, never for_each a sensitive value directly. |
403 / Resource not accessible on org secrets | Provider identity lacks org rights | Token/App needs admin:org (classic) or Organization → Codespaces secrets: read/write (fine-grained). See SCOPE.md. |
403 on user secrets | Missing user scope | Classic PAT needs codespace:secrets; fine-grained needs Codespaces user secrets: read/write on the user. |
| Apply fails creating any Codespaces secret | Codespaces not enabled for the org / not billed | Enable Codespaces and configure billing first. Many regulated orgs disable Codespaces — confirm it is part of the footprint. |
| Org secret apply fails on a Free org | Org Codespaces secrets need Team / Enterprise Cloud | Upgrade the plan or scope secrets to repo/user level. |
selected_repository_ids rejected / no effect | Wrong id type, or set on a non-selected secret | Use numericrepo_id (wire module.repository.repo_id), and only with visibility = "selected". |
| Allow-list keeps reverting | Allow-list owned in two places | Set selected_repository_ids inline or via secret_repository_access for a given secret — never both. |
| Value changed in the GitHub UI not detected | Values are write-only | The provider can't read values back; re-apply from code (the source of truth), or use ignore_changes for externally-rotated secrets. |
| Secondary rate limit on a big apply | Many secrets = many writes + key fetches | Split large for_each maps across applies. |
- integrations/github provider — Codespaces Secret, Codespaces Organization Secret, Codespaces Organization Secret Repositories, and Codespaces User Secret resource references
- integrations/github provider — Codespaces public-key data sources (repo / organization / user) for
encrypted_value - module — terraform-github-repository (keystone; emits
idandrepo_id) - module — terraform-github-dependabot (the direct mirror — repo + org + allow-list)
- module — terraform-github-actions-repository / terraform-github-repository-environment (sibling secrets surfaces)
- GitHub Docs — Managing encrypted secrets for your Codespaces, and the Codespaces organization/billing policy matrix