Secrets Management with Simple Container
This comprehensive guide covers how to manage secrets and confidential files using Simple Container's built-in secrets management system. Simple Container encrypts secrets to your team's SSH public keys (RSA and ed25519) so they can be stored and shared safely in your Git repository.
Overview
Simple Container's secrets management provides:
- Public-key encryption to SSH recipients (RSA and ed25519) for secure secret storage
- Team-based access control with public key management
- Git-native workflow for secret versioning and collaboration
- Built-in commands for easy secret lifecycle management
- Automatic encryption/decryption during deployment
- Multi-key encryption - each secret is encrypted with all authorized public keys
How Encryption Works
Simple Container uses a multi-key encryption approach:
- Public keys are registered in
.sc/secrets.yamlfile - Every secret file is encrypted with ALL registered public keys
- Anyone with an authorized private key can decrypt secrets using
sc secrets reveal - Adding/removing team members requires re-encrypting all secrets
This means that when you run sc secrets reveal, the system uses your private key to decrypt secrets that were encrypted with your corresponding public key (along with all other team members' public keys).
Cipher per recipient type
Each recipient public key gets its own encryption of the secret, using a scheme appropriate to the key type:
ssh-rsarecipients — RSA-OAEP (SHA-256).ssh-ed25519recipients — an ephemeral-static X25519 ECDH sealed box (HKDF-SHA256 + ChaCha20-Poly1305): the content key is derived from the ECDH shared secret, so only the holder of the ed25519 private key can decrypt.
Store format & version compatibility
The encrypted store .sc/secrets.yaml carries an optional schemaVersion field
that identifies its schema. The current format is schema version 0 — written
without an explicit schemaVersion: field, so existing stores are unchanged.
sc is fail-closed on this: a build refuses to read a store whose
schemaVersion is newer than it understands, rather than silently dropping fields
it cannot model and corrupting the store on the next write. The error looks like:
secrets file ".sc/secrets.yaml" is schema version N, but this sc build supports up
to schema version M; upgrade sc (refusing to read to avoid data loss)
This guarantee holds on every path — the local CLI and the GitHub Actions
deploy/reveal flows (a too-new store halts the run instead of falling back to
"no secrets"). The practical implication: keep sc up to date across your team
and CI before any newer store format is adopted, so that no older binary is
left unable to read — or, worse, able to overwrite — the new on-disk schema.
Prerequisites
Before working with secrets, ensure you have:
- Simple Container CLI installed
- An SSH key pair — RSA (2048-bit) or ed25519
- Access to the project repository
Configuration Override
Simple Container supports configuration override via the SIMPLE_CONTAINER_CONFIG environment variable. This is particularly useful for CI/CD environments where you want to provide configuration without creating files:
# Override configuration via environment variable (using explicit keys)
export SIMPLE_CONTAINER_CONFIG="
projectName: your-project-name
privateKey: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----
publicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user@host'
"
# Or using key paths
export SIMPLE_CONTAINER_CONFIG="
privateKeyPath: /path/to/ci/private/key
publicKeyPath: /path/to/ci/public/key
projectName: your-project-name
"
# Now sc commands will use the environment configuration
sc secrets reveal
Configuration Content Options:
Configuration files (.sc/cfg.default.yaml or .sc/cfg.<profile>.yaml) and the SIMPLE_CONTAINER_CONFIG environment variable can contain:
-
Key Paths (typical for local development):
-
Explicit Key Content (typical for CI/CD environments):
This approach is recommended for CI/CD pipelines as it avoids creating configuration files and allows for secure key management through environment variables.
Install Simple Container CLI
SSH Key Requirements
Simple Container currently supports SSH RSA keys of size 2048 bits. Your SSH key pair should be:
- Private key:
~/.ssh/id_rsa(never commit or share) - Public key:
~/.ssh/id_rsa.pub(safe to share) - No passphrase: Keys should not have a passphrase for automation
Generate SSH Keys
If you don't have SSH keys, generate them:
# Using ssh-keygen
ssh-keygen -t rsa -b 2048 -f ~/.ssh/id_rsa -N ""
# Or using sc (generates and configures automatically)
sc secrets init -g
Core Secrets Commands
sc secrets init
Initialize secrets management in your project or configure your local environment.
# Initialize with existing SSH key
sc secrets init
# Generate new SSH key and initialize
sc secrets init -g
# Generate separate key pair for CI/CD environment
sc secrets init --profile github --generate
# Initialize with verbose output
sc secrets init -g --verbose
# Initialize and make initial commit
sc secrets init -g --commit
What it does:
- Creates
.sc/cfg.default.yamlconfiguration file (or.sc/cfg.<profile>.yamlwhen using--profile) - Configures your SSH key paths or embeds key contents
- Sets up local secrets environment
- When using
--profile, creates separate configuration for different environments (e.g., CI/CD) - Configuration can be overridden using
SIMPLE_CONTAINER_CONFIGenvironment variable
Example output:
# .sc/cfg.default.yaml (default profile - using key paths)
privateKeyPath: ~/.ssh/id_rsa
publicKeyPath: ~/.ssh/id_rsa.pub
projectName: simple-container-api
# .sc/cfg.github.yaml (when using --profile github - using key paths)
privateKeyPath: .sc/profiles/github/id_rsa
publicKeyPath: .sc/profiles/github/id_rsa.pub
projectName: simple-container-api
# Example with explicit key content (typical for CI/CD)
projectName: simple-container-api
privateKey: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1234567890abcdef...
-----END RSA PRIVATE KEY-----
publicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ1234567890abcdef... user@host"
sc secrets list
Display all available secrets in the current project.
# List all secrets
sc secrets list
# List secrets with verbose output
sc secrets list --verbose
# List secrets using specific profile
sc secrets list --profile github
Example output:
pkg/secrets/database-password.txt
pkg/secrets/api-keys.json
pkg/secrets/ssl-cert.pem
.sc/stacks/mystack/secrets.yaml
config/production.env
sc secrets add
Add new secrets to the encrypted store.
# Add a single secret file
sc secrets add database-password.txt
# Add multiple files
sc secrets add api-keys.json ssl-cert.pem
# Add multiple files at once
sc secrets add config-dev.env config-prod.env
# Add with verbose output
sc secrets add --verbose database-password.txt
# Add using specific profile
sc secrets add --profile github database-password.txt
What it does:
- Encrypts the specified files/directories
- Adds encrypted versions to Git tracking
- Removes plaintext versions from Git (adds to .gitignore)
- Updates secrets index
sc secrets reveal
Decrypt and reveal encrypted secrets for local use.
# Reveal all secrets
sc secrets reveal
# Reveal all secrets (no support for specific files)
sc secrets reveal
# Force reveal all secrets
sc secrets reveal --force
# Reveal with verbose output
sc secrets reveal --verbose
What it does:
- Decrypts encrypted secret files using your private key
- Creates plaintext versions for local development
- Updates .gitignore to prevent accidental commits
sc secrets allow
Grant access to secrets by adding team members' public keys.
# Allow access for a team member (using public key content)
sc secrets allow "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... username@host"
# Allow multiple users (using public key content)
sc secrets allow "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user1@host" "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user2@host"
# Allow with verbose output
sc secrets allow --verbose "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... username@host"
# Allow using specific profile
sc secrets allow --profile github "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... username@host"
# Allow by reading from file (practical example)
sc secrets allow "$(cat ~/.ssh/id_rsa.pub)"
sc secrets allow "$(cat /path/to/teammate.pub)"
What it does:
- Adds public key to authorized keys list in
.sc/secrets.yaml - Re-encrypts all secrets with the new key included
- Updates team access permissions
- Requires the actual public key content as argument, not a file path
sc secrets disallow
Revoke access to secrets by removing team members' public keys.
# Revoke access for a team member (using public key content)
sc secrets disallow "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... username@host"
# Revoke multiple users (using public key content)
sc secrets disallow "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user1@host" "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user2@host"
# Revoke with verbose output
sc secrets disallow --verbose "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... username@host"
# Revoke using specific profile
sc secrets disallow --profile github "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... username@host"
# Revoke by reading from file (practical example)
sc secrets disallow "$(cat /path/to/former-teammate.pub)"
What it does:
- Removes public key from authorized keys list in
.sc/secrets.yaml - Re-encrypts all secrets without the removed key
- Revokes access permissions
- Requires the actual public key content as argument, not a file path
sc secrets hide
Hide (encrypt) repository secrets.
# Hide all secrets in the repository
sc secrets hide
# Force re-encrypt all secrets (useful after key changes)
sc secrets hide --force
# Hide with verbose output
sc secrets hide --verbose
# Hide using specific profile
sc secrets hide --profile github
What it does:
- Encrypts all secret files in the repository
- Updates encrypted versions with current authorized keys
- Removes plaintext versions and updates .gitignore
- Use
--forceto re-encrypt even if secrets appear up-to-date
sc secrets reveal
Reveal (decrypt) repository secrets for local use.
# Reveal all secrets
sc secrets reveal
# Force decrypt all secrets
sc secrets reveal --force
# Reveal with verbose output
sc secrets reveal --verbose
# Reveal using specific profile
sc secrets reveal --profile github
What it does:
- Decrypts all encrypted secret files using your private key
- Creates plaintext versions for local development
- Updates .gitignore to prevent accidental commits of plaintext secrets
- Use
--forceto decrypt even if plaintext versions already exist
sc secrets delete
Delete repository secrets.
# Delete specific secret files
sc secrets delete database-password.txt
# Delete multiple secrets
sc secrets delete api-keys.json ssl-cert.pem
# Delete with verbose output
sc secrets delete --verbose config-file.env
# Delete using specific profile
sc secrets delete --profile github secret-file.txt
What it does:
- Removes specified secret files from the encrypted store
- Cleans up both encrypted and plaintext versions
- Updates the secrets registry
sc secrets allowed-keys
List public keys allowed to decrypt secrets.
# List all allowed public keys
sc secrets allowed-keys
# List with verbose output
sc secrets allowed-keys --verbose
# List using specific profile
sc secrets allowed-keys --profile github
What it does:
- Displays all public keys registered in
.sc/secrets.yaml - Shows which keys can decrypt the repository secrets
- Useful for auditing team access
Team Collaboration Workflow
Setting Up Team Access
-
Project Administrator initializes secrets:
-
Team Members join:
-
Administrator grants access:
-
Team Members access secrets:
Adding New Secrets
# Add new secret file
echo "secret-value" > new-secret.txt
sc secrets add new-secret.txt
# Commit changes
git add . && git commit -m "Add new secret"
git push
Rotating Secrets
# Update secret content
echo "new-secret-value" > database-password.txt
# Re-encrypt with updated content
sc secrets add database-password.txt
# Commit changes
git add . && git commit -m "Rotate database password"
git push
Removing Team Member Access
# Revoke access and re-encrypt all secrets (using public key content)
sc secrets disallow "$(cat former-team-member.pub)"
# Or directly use the public key content:
# sc secrets disallow "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... former-team-member@host"
# Commit security changes
git add . && git commit -m "Revoke access for former team member"
git push
Best Practices
Security Best Practices
-
Never commit private keys:
-
Regular key rotation:
-
Audit access regularly:
Development Workflow
-
Start development session:
-
End development session:
-
Adding new secrets during development:
CI/CD Integration
Setting Up CI/CD Secrets Access
For automated deployments, you can create separate key pairs for CI/CD environments:
# Generate CI/CD specific key pair
sc secrets init --profile github --generate
# This creates:
# .sc/cfg.github.yaml - CI/CD configuration
# .sc/profiles/github/id_rsa - CI/CD private key
# .sc/profiles/github/id_rsa.pub - CI/CD public key
Add CI/CD public key to team access:
# Administrator adds CI/CD public key to the chain
# Note: Use the actual public key content, not the file path
sc secrets allow "$(cat .sc/profiles/github/id_rsa.pub)"
git add . && git commit -m "Add CI/CD access"
git push
Important: When using sc secrets allow, you must provide the actual SSH public key content, not the file path. The generated public key needs to be added to the encryption chain so that secrets can be decrypted using the corresponding private key.
In your CI/CD pipeline (e.g., GitHub Actions):
Method 1: Using SIMPLE_CONTAINER_CONFIG environment variable (Recommended)
# .github/workflows/deploy.yml
- name: Setup and deploy with secrets
env:
# Reference the entire .sc/cfg.github.yaml content from GitHub Actions secret
SIMPLE_CONTAINER_CONFIG: ${{ secrets.SC_CONFIG }}
run: |
# Reveal secrets (uses SIMPLE_CONTAINER_CONFIG)
sc secrets reveal
# Deploy with secrets available
sc deploy -s myservice -e staging
Setup Instructions: 1. Create your CI/CD configuration locally:
-
Copy the entire content of
.sc/cfg.github.yamlto a GitHub Actions secret namedSC_CONFIG: -
Add the CI/CD public key to your project:
Alternative approaches:
Method 2: Using profile-based configuration
# .github/workflows/deploy.yml
- name: Setup CI/CD secrets
run: |
# Copy CI/CD private key from secrets
mkdir -p .sc/profiles/github
echo "${{ secrets.SC_GITHUB_PRIVATE_KEY }}" > .sc/profiles/github/id_rsa
chmod 600 .sc/profiles/github/id_rsa
# Reveal secrets using CI/CD profile
sc secrets reveal --profile github
- name: Deploy with secrets
run: |
# Now secrets are available for deployment
sc deploy -s myservice -e staging --profile github
For deployment configuration, secrets can be injected via environment variables:
# In your deployment configuration
env:
DATABASE_URL: ${secret:database-url}
API_KEY: ${secret:api-key}
Important: For this to work, secret values must be stored in .sc/stacks/<parent-stack>/secrets.yaml under the values section:
# .sc/stacks/your-parent-stack/secrets.yaml
values:
database-url: "postgresql://<USER>:<PASS>@<host>:5432/db"
api-key: "your-secret-api-key-here"
Simple Container automatically decrypts and injects secrets during deployment by reading from the parent stack's secrets.yaml file.
Runtime secrets on Yandex Cloud — Lockbox references
On YC-based deploys, ${secret:…} and secrets: on a client stack behave
differently from a plain env-var injection. Simple Container packages every
runtime secret into a Yandex Cloud Lockbox secret and hands the container
revision references to that secret rather than the plaintext values.
Two consequences worth remembering:
-
secrets.yamlis config-time, Lockbox is runtime.${secret:MY_TOKEN}on a client stack resolves at deploy time — SC reads the plaintext from the encrypted.sc/stacks/<parent>/secrets.yaml, then writes it into a newLockboxSecretVersionentry. The container revision receives a reference{secret: <id>, key: <name>}per entry, and YC injects the plaintext into the container's environment at cold start. The container process itself never sees a${secret:…}placeholder. -
A zero-entry secret is rejected by YC. If a stack declares no
secrets:entries at all, SC creates no Lockbox resource — aLockboxSecretVersionwith noentriesfails at apply time. Add a runtime secret to trigger the whole chain.
There is no ${lockbox:…} interpolation and won't be — Lockbox is the
delivery mechanism for what ${secret:…} already refers to, not a separate
addressing scheme.
On destroy. The Lockbox secret enters YC's usual PENDING_DELETE state
(default 7-day window, though a zero-entry stack may hard-delete). This is
YC platform behaviour, not an SC bug.
For a working example of a client stack pushing runtime secrets into Lockbox, see Yandex Cloud.
Troubleshooting
Common Issues
"Permission denied" errors:
# Check SSH key permissions
chmod 600 ~/.ssh/id_rsa
chmod 644 ~/.ssh/id_rsa.pub
# Check allowed keys
sc secrets allowed-keys
"Cannot decrypt secrets":
# Check if your key is allowed
sc secrets allowed-keys
# Re-initialize if needed
sc secrets init
# Contact administrator to grant access
"Secrets out of sync":
Getting Help
# Get help for any command
sc secrets --help
sc secrets init --help
sc secrets add --help
sc secrets allow --help
# Check version and configuration
sc --version
sc secrets allowed-keys --verbose
# Check allowed keys for specific profile
sc secrets allowed-keys --profile github --verbose
Per-scope secrets (secrets.<scope>.yaml)
The whole-file store (.sc/secrets.yaml) is decrypted by a single master key
(SIMPLE_CONTAINER_CONFIG) — every CI context that needs any secret can read all of
them. Per-scope secrets let you carve out a named subset (a "scope") sealed to its own
recipient set, so a narrow, attacker-reachable context — typically a pull_request-triggered
scan job — can hold a key that decrypts only that scope.
Scoped values live in committed, encrypted files alongside a stack's config:
.sc/
scopes.yaml # scope -> recipient keys (governance; CODEOWNERS-gate this)
stacks/<stack>/
secrets.yaml # legacy whole-file store (unchanged)
secrets.pr.yaml # scope "pr": committed encrypted, structure diffable, values opaque
stacks/ here is the stacks directory deploys read from: the -d/--dir value, else stacksDir
in the profile config file, else .sc/stacks. A relative path is relative to the repository
root. Every sc secrets scope command resolves it the same way and accepts -d/--dir, so a scope
file is written where the deploy reads it. scopes.yaml always stays in .sc/. If a repo
sets a custom stacksDir, sc secrets scope lint warns about scope files left under
.sc/stacks, since deploys will not read them.
A recipient is either an SSH public key (ssh-ed25519 / ssh-rsa) or an AWS KMS key
(awskms://<key>?region=<region>). Each value is encrypted once under a random data key bound
to its (stack, scope, key); that data key is then wrapped for every recipient. A ciphertext
therefore cannot be moved to another stack, scope, or key, and every recipient decrypts the
same value. Old sc binaries never read these files (they fail closed on the newer schema).
- SSH recipient — the private key must be provided to decrypt (a file, a CI secret, or the ambient config). Good for humans and for the interim CI key.
- KMS recipient — the data key is wrapped with
kms:Encryptand unwrapped withkms:Decrypt, using the caller's ambient AWS credentials. In CI those come from an OIDC-federated role, so no private key is stored anywhere — this is the recommended end state for CI (see "KMS recipients" below).
Commands
# Declare a scope and its recipients (edit .sc/scopes.yaml behind a CODEOWNERS gate).
# A recipient is an SSH public key OR an awskms:// URL.
sc secrets scope allow --scope pr <ssh-pubkey> # add an SSH recipient + reseal
sc secrets scope allow --scope pr 'awskms://alias/sc-pr?region=us-east-1' # add a KMS recipient + reseal
sc secrets scope disallow --scope pr <recipient> # remove a recipient + reseal (then rotate values!)
# Manage values in a scope.
sc secrets scope set --scope pr -s <stack> KEY <value> # or '-' to read the value from stdin
sc secrets scope set --scope pr -s <stack> -- KEY -v # '--' before KEY when the value starts with '-'
sc secrets scope get --scope pr -s <stack> KEY
sc secrets scope list --scope pr -s <stack>
sc secrets scope delete --scope pr -s <stack> KEY
# Guardrails.
sc secrets scope lint # CI gate: encryption shape, recipient drift, scope/stack binding, duplicate keys
sc secrets scope doctor # which scopes the current key can open
Commit only the encrypted secrets.<scope>.yaml and scopes.yaml; never the legacy
plaintext stacks/*/secrets.yaml.
How the commands behave at the edges:
allowanddisalloweditscopes.yamlin place: comments, order, indentation and unknown keys stay, though list items written flush with their key are re-indented. They also reseal any scope file whose recipients drifted fromscopes.yaml, which is how lint's "recipients drift" is fixed. A recipient is one key on one line; surrounding whitespace is trimmed. They refuse to edit ascopes.yamlthey cannot edit safely (one that does not parse, or uses YAML anchors, aliases or merge keys) rather than rewrite it.setneeds the value, or-for stdin (put--before KEY when the value starts with-). This is a change:set KEYwithout a value used to read stdin. The stack must be an existing directory in the stacks dir; stack names are one path segment of letters, digits,_,.and-, starting with a letter, digit or_.- A
secrets.<scope>.yamlthat does not parse, is not a mapping or is zero bytes long (merge-conflict markers, a truncated write; sc never writes an empty file) fails lint, the deploy read and resealing, with a hint to rename it if it is not a scope file. A file of only whitespace or comments, or a readable mapping with none of thestack,scopeandrecipientskeys such as a plaintextsecrets.example.yaml, is ignored, and both lint and the deploy log say so. Runsc secrets scope lintbefore upgrading: a file it now rejects would fail deploys. - Commands that change scope files or
scopes.yamllock the.scdirectory, so parallelsetcalls do not lose each other's values. The lock is an advisoryflockon the directory; where the filesystem cannot lock a directory (NFS and some network mounts) a.sc/.scope-store.lockfile is locked instead, so add it to.gitignorethere. A waiting command says so after five seconds and gives up after two minutes (SC_SCOPE_LOCK_TIMEOUT, a Go duration, changes that). Do not rungit checkoutorgit cleanon.scwhile a command runs. The store is not locked on Windows. - A key given through
--key-file,SC_SCOPE_KEY, orSC_KEY_<SCOPE>for a scope inscopes.yaml, that does not parse stops the command, naming where it came from, rather than reading as "not a recipient". A deploy logs it as a warning, once. AnSC_KEY_*variable whose suffix names no scope is not a scope key and is left alone. - An existing
scopes.yamlkeeps its indentation; a new one is written with two spaces.
Using a scope key in CI
Give the job the scope's private key and it decrypts only that scope — no
SIMPLE_CONTAINER_CONFIG required. get (and deploy-time ${secret:} resolution) look up
the decryption key in order: --key-file, then env SC_KEY_<SCOPE> (e.g. SC_KEY_PR) or the
generic SC_SCOPE_KEY, then the ambient SIMPLE_CONTAINER_CONFIG.
# a pull_request scan job — holds ONLY the pr-scope key
env:
SC_KEY_PR: ${{ secrets.SC_KEY_PR }} # ssh-ed25519 private key, recipient of the "pr" scope only
steps:
- run: DD_KEY=$(sc secrets scope get --scope pr -s integrail defectdojo-api-key)
At deploy time, ${secret:KEY} and sc stack secret-get transparently include every scope
the job's key can open, merged over the whole-file store (the legacy store wins on conflict). A key may sit in
several scopes with the same value; different values fail the deploy, and
sc secrets scope lint fails on them when it can open the copies (it warns when it cannot).
Scopes do not give a key a value per environment: ${secret:KEY} has no environment axis,
and a key that opens several scopes (an admin's, or a parent provision that reads every
environment) sees them all. Name per-environment values per environment, as with the
whole-file store: staging-db-password in the staging scope and prod-db-password in
the prod scope.
A key in both a scope and the whole-file store fails lint unless --allow-legacy-duplicates
is given, which is meant for the period when clients are moving off the store. A key that is not a recipient of a scope cannot decrypt it — the
pull_request clamp is cryptographic, not a config flag.
Rotation:
disallowre-encrypts current files but does NOT rewrite git history — that recipient can still read previously committed versions. Always rotate the scope's values after removing a recipient.
Runbook: a leaked scope key
disallow stops a removed recipient from reading future commits, but the values it could
already read must be treated as compromised — git history is immutable. To revoke a leaked
scope key (SSH or KMS):
# 1. Remove the leaked recipient from the scope (reseals current files to the rest).
sc secrets scope disallow --scope pr <leaked-ssh-pubkey | awskms://…>
# 2. ROTATE every value in the scope at its source, then re-set it — the old ciphertext in
# git history is still readable by the leaked key, so the values themselves must change.
sc secrets scope list --scope pr -s <stack> # enumerate what to rotate
# for each KEY: rotate upstream (new API token, etc.), then:
sc secrets scope set --scope pr -s <stack> KEY <new-value>
# 3. Commit the resealed scope file(s) + scopes.yaml.
git add .sc/scopes.yaml <stacks-dir>/<stack>/secrets.*.yaml && git commit -S -s -m "rotate pr scope after key revocation"
Then, depending on the recipient kind:
- SSH scope key (
SC_KEY_PR) — delete/replace the GitHub secret; the old private key can no longer decrypt the rotated values (and is no longer a recipient going forward). - KMS recipient — the "key" is the IAM permission to call
kms:Decrypt, not a stored secret. Revoke it by removingkms:Decryptfrom the compromised role/principal (or, if the KMS key itself is suspect, schedule key deletion / rotate to a new key and re-allowit). Because decryption requires assuming the OIDC role, tightening the role's trust policy or IAM immediately cuts off access — no secret to invalidate.
Prefer at least one SSH break-glass recipient on every scope so you can always reseal in
step 1 even if the KMS path is unavailable, and keep sc secrets scope lint a required CI
check so the collision/duplicate guarantees are enforced, not just advisory.
KMS recipients (keyless CI, no stored key)
A stored SC_KEY_PR is a smaller master key, but it is still a long-lived private key sitting
in a CI secret. A KMS recipient removes it entirely: the scope's data keys are wrapped for
an AWS KMS key, and a CI job unwraps them with an OIDC-federated IAM role — so the job
proves its identity to AWS per-run and holds no secret key at all.
The recipient string is sc's canonical KMS URL — the same form the Pulumi state secrets
provider uses:
Add the KMS recipient (needs kms:Encrypt on the key for the reseal). Keep at least one
SSH break-glass recipient so you can always reseal/recover if the KMS path is unavailable:
# scopes.yaml already has an SSH break-glass recipient; add the KMS key beside it.
# Prefer the key ARN over an alias (see "Secure-usage rules" — an alias can be repointed).
sc secrets scope allow --scope pr 'awskms://arn:aws:kms:us-east-1:123456789012:key/abcd-1234?region=us-east-1'
sc secrets scope set --scope pr -s integrail defectdojo-api-key 'dd-…' # sealed to BOTH recipients
The resulting scope file carries one opaque KMS wrap per value keyed by the KMS URL, alongside the SSH wrap — no plaintext, fully diffable structure:
schemaVersion: 1
stack: integrail
scope: pr
recipients:
- awskms://arn:aws:kms:us-east-1:123456789012:key/abcd-1234?region=us-east-1
- ssh-ed25519 AAAA…break-glass
values:
defectdojo-api-key:
ciphertext: <AEAD value under a random data key>
wraps:
"awskms://arn:aws:kms:us-east-1:123456789012:key/abcd-1234?region=us-east-1": [<data key wrapped by KMS>]
"SHA256:…break-glass": [<data key sealed to the SSH key>]
Consume it in CI with OIDC — no SC_KEY_PR, no SIMPLE_CONTAINER_CONFIG:
# a pull_request scan job — federates to AWS, then decrypts only the "pr" scope
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::<account>:role/ci-oidc-pr-scan # kms:Decrypt on the pr key only
aws-region: us-east-1
- run: DD_KEY=$(sc secrets scope get --scope pr -s integrail defectdojo-api-key)
get, doctor, and deploy-time ${secret:} resolution all try, in order: an explicit
--key-file, the SC_KEY_<SCOPE> / SC_SCOPE_KEY env keys, the ambient
SIMPLE_CONTAINER_CONFIG, and finally a KMS Decrypt for any awskms:// recipient using the
ambient AWS credentials, or a Cloud KMS Decrypt for a gcpkms:// recipient using Application
Default Credentials. A value with no KMS recipient never triggers a cloud call.
Secure-usage rules for KMS recipients
- Prefer a key ARN as the recipient, not an alias.
awskms://alias/…is convenient, but an alias only pins the key by name — an account admin who repoints the alias changes which key the recipient resolves to. The key ARN (awskms://arn:aws:kms:<region>:<acct>:key/<id>) is the durable security boundary; use it for anything sensitive and treat aliases as a convenience for low-risk scopes.scpins theKeyIdon every decrypt, so a value wrapped under a different key than the recipient names is rejected as a hard integrity error. - Scope the IAM role tightly — pin stack AND scope. Allow
kms:Decrypton only the scope's key, and constrain the encryption context.scsets it to{sc:domain, sc:stack, sc:scope, sc:key}, so condition on bothsc:stackandsc:scope:
"Condition": { "StringEquals": {
"kms:EncryptionContext:sc:scope": "pr",
"kms:EncryptionContext:sc:stack": "integrail"
}}
Conditioning on sc:scope alone lets one role decrypt every stack's pr values wrapped
under that key — fine if one key serves one trust level and stacks are mutually trusted, but
add sc:stack when stacks must not read each other. KMS enforces the context server-side, so
a value transplanted to another stack/scope/key fails to decrypt.
- Never give the PR-scan role kms:Decrypt on a deploy/prod key. The whole point is that
a compromised PR job cannot reach deploy-grade secrets. Use a separate key (and role) per
trust level; a deploy scope's key stays out of the PR role's policy.
- The clamp is cryptographic. A role that is not a recipient of secrets.prod.yaml (no
kms:Decrypt on its key) cannot open it, regardless of anything in a PR's config.
- The stack/scope/key are NOT secret. They ride as the KMS EncryptionContext and are logged
in CloudTrail (desirable for audit) — do not put a secret-ish string in a stack, scope, or
key name expecting it to stay private.
- How failures are classified. A KMS InvalidCiphertext (mangled blob or broken
stack/scope/key binding) or IncorrectKey (the wrap was made under a different key than the
recipient names — a transplant) is a hard integrity error that fails the deploy as tamper.
AccessDenied, a disabled key, or no ambient credentials is a least-privilege skip (the
scope is simply not yours; a missing required ${secret:} then fails closed downstream). A
transient KMS fault (throttle after SDK retries, KMSInternal, timeout) fails the deploy as
unavailable (retry) — never silently and never mislabeled as tamper.
- Rotation still applies. Removing a KMS recipient re-encrypts current files but does not
rewrite history — rotate the values, and additionally rotate/disable the KMS key material if
the key itself is the concern.
Cloud KMS recipients (gcpkms://)
The same keyless model on Google Cloud. The recipient names a symmetric CryptoKey (not a key version: Encrypt uses the primary version and Decrypt finds it from the ciphertext, so rotation needs no reseal):
sc secrets scope allow --scope app-staging 'gcpkms://projects/acme/locations/europe-west1/keyRings/sc/cryptoKeys/app-staging'
Sealing needs roles/cloudkms.cryptoKeyEncrypter on the key; opening needs
roles/cloudkms.cryptoKeyDecrypter, which in CI is granted to the identity the job gets
through Workload Identity Federation (google-github-actions/auth). Grant it on that one key
only.
- The value's
(stack, scope, key)is sent as the KMS additional authenticated data, which KMS enforces: a wrap moved to another stack, scope or key does not decrypt. - Decrypt names the key from the recipient, so a wrap filed under another key's slot is rejected.
- CRC32C checksums are sent and verified in both directions, and Encrypt must report a version of the named key.
INVALID_ARGUMENTis an integrity failure (deploy fails as tamper);PERMISSION_DENIED,NOT_FOUND,FAILED_PRECONDITION(key disabled or destroyed) andUNAUTHENTICATEDmean "not your scope" and are skipped; anything else fails the deploy as unavailable (retry). No Application Default Credentials are probed once per resolve.
Client deploys without the master key
A client (consumer) deploy normally needs the parent's whole-file store: its ${auth:...}
entries and the values its client.yaml references. Scopes can replace that, per client and
per environment, so a leaked CI credential exposes one application's secrets instead of the
platform's.
- Give the parent's GCP auth entry no key, so it holds no secret, and seal it into the
client's scope as an
auth:entry:
printf 'type: gcp-service-account\nconfig:\n projectId: acme\n credentials: ""\n' |
sc secrets scope set --scope app-staging -s infra auth:gcloud -
- Seal the values that deploy references into the same scope: the app's own secrets and any parent value it needs (a DNS token for its domains, a notification token).
- Make the scope's recipients a break-glass SSH key and a
gcpkms://key the client's CI identity can decrypt with. - In the client workflow, federate to GCP before deploying (
google-github-actions/auth; a checkout step is not required). The deploy'sSIMPLE_CONTAINER_CONFIGcarries only a key that can clone the parent repository read-only and is not a recipient of itssecrets.yaml.
If an earlier step of the same job revealed the parent's whole-file store (a legacy deploy
run side by side with a keyless one, for example), the deploy removes that plaintext
secrets.yaml from the workspace before copying the parent's stacks, for every parent stack
that has scope files. Otherwise it would win over the scopes this deploy opens.
With scope files present and the whole-file store unreadable, a placeholder that no openable
scope fills fails the deploy before anything is changed, naming the placeholder. Only what the
deploy consumes is checked: the client config, its template, the resources it lists in
uses and dependencies, the registrars and the provisioner. Placeholders of other
environments, sibling stacks' resources and unused templates are ignored. Without scope files nothing changes: an unresolved
placeholder is logged and deployed as before.
Summary
Simple Container's secrets management provides a secure, Git-native way to handle sensitive data in your projects. Key benefits:
- Secure: SSH-RSA encryption with team-based access control
- Simple: Easy-to-use commands for all secret operations
- Collaborative: Git-based workflow for team secret sharing
- Integrated: Seamless integration with Simple Container deployments
- Scoped: Per-scope recipients (SSH keys or KMS-via-OIDC) so a narrow CI context decrypts only what it needs — with the KMS path storing no key at all
Use the commands outlined in this guide to implement robust secrets management in your Simple Container projects.