Risk and guardrails
A risk level on every plan, notes on the code, change freezes, protected resources, scoped overrides, and a scan for leaked secrets in plan output.
New in 0.2.0. Upgrade teriac server to 0.2.0 or later to use the settings on this page.
These settings go in repos.yaml: at the top (every repo) or in a repo's entry (that repo only).
When both are set:
| Settings | Combined how | |
|---|---|---|
| Lists add up | freezes, protected, policies.rules, risk.stateful_types |
The repo's entries are added to the server-wide ones |
| Only stricter | secret_scan, policies.mode, risk.size_limits |
The server-wide value is the minimum; a repo can raise it, never lower it |
| Repo wins | anything else, e.g. risk.production, risk.extra_approval, policies.override |
The repo's value replaces the server-wide one for that repo |
A repo's own teriac.yaml is different: repo owners control it, so it can only tighten. See
rules and permissions.
Risk score
Every plan with changes gets a risk level. It comes from fixed rules, so the same plan always gets the same score. The PR comment's Risk column shows the level, or None when nothing risky was found. The reasons are in each project's dropdown and on its check run.
| Level | When the plan |
|---|---|
| 🔴 Critical | destroys or replaces a stateful resource (database, bucket, KMS key, DNS zone, cluster, volume…) in production |
| 🟠 High | does that outside production; destroys anything in production; changes access (IAM, roles, policies) in production; changes more than 100 resources (size_limits.high); has high or critical policy findings |
| 🟡 Medium | destroys anything elsewhere; changes access or network resources; changes more than 25 resources (size_limits.medium); has medium policy findings; changes production at all |
| None | anything else |
risk: # in repos.yaml (server-wide or per repo)
production: ["prod/**", "live/prod/**"] # default: folders named prod, production or prd
stateful_types: ["mongodbatlas_cluster", "snowflake_database", "confluent_kafka_*"] # added to the built-in list
size_limits: { medium: 25, high: 100 } # changed resources that make a plan medium / high
extra_approval:
high: { from: team:sre, count: 1 } # high and critical plans need one SRE approval
critical: { from: team:sre, count: 2 } # critical plans need two| Key | Default | |
|---|---|---|
production |
folders named prod, production or prd |
Project globs that count as production |
stateful_types |
built-in list | Resource types to treat as stateful, added to the built-in list. Globs work |
size_limits |
{ medium: 25, high: 100 } |
How many changed resources make a plan Medium or High. A repo can lower them, never raise them |
extra_approval |
Approvals a plan needs on top of the project's own. Each applies at its level and above, so high covers critical plans too |
What counts as stateful
Built in:
- databases and clusters: RDS, Aurora, DynamoDB, Cloud SQL, Cosmos DB, Redis, OpenSearch, Kafka…
- storage: S3, GCS, Azure storage, EBS, EFS, disks
- keys and secrets: KMS, Key Vault, Secrets Manager
- DNS zones
- Kubernetes clusters, namespaces and volumes
- backup vaults and CloudTrail
stateful_types adds your own.
Policy findings raise it too
Each policy rule has a severity, and findings feed the score. A finding from a
critical rule makes the plan at least High.
Notes on the code
The check run also annotates the code. Every resource block that will be destroyed or replaced, every protected resource the plan touches, and every policy finding shows up on its line in the PR's Files changed tab. Reviewers see the risk where the change is.
Change freezes
Stop applies during launches, holidays or weekends. freezes is a list of windows:
freezes:
- { name: weekend, projects: ["prod/**"], schedule: "0 15 * * 5", hours: 63, timezone: Europe/Berlin, override: [team:sre] } # Fri 15:00 → Mon 06:00
- { name: holidays, from: "2026-12-23", to: "2027-01-02" } # whole days, both included; no override: nobody can skip it| Key | ||
|---|---|---|
name |
required | Shown when an apply is refused |
projects |
every project | Project globs |
schedule + hours |
A recurring window: starts on the cron schedule and lasts this many hours (1 to 336) | |
from + to |
Or a date range, YYYY-MM-DD, whole days, both included |
|
timezone |
UTC | IANA name, e.g. Europe/Berlin |
override |
nobody | Who may apply anyway with --override |
Use either schedule + hours or from + to in one window, not both.
During a freeze, plans still run, but applies and state edits (import, state rm, …) are
refused, with the time the freeze ends.
Protected resources
Resources that must never be destroyed or replaced, whatever the plan says. protected is a list:
protected:
- { projects: ["prod/**"], resources: ["aws_db_instance.*", "aws_kms_key.*"], override: [team:sre] }
- { resources: ["aws_s3_bucket.audit_logs"] } # every project; no override: nobody can skip it| Key | ||
|---|---|---|
resources |
required | Address globs. They match inside modules too: aws_db_instance.* covers module.db.aws_db_instance.main[0] |
projects |
every project | Project globs |
override |
nobody | Who may destroy or replace them anyway with --override |
A plan that would destroy or replace one is refused at apply, and the block is marked on the code.
Overrides
Each freeze window and each protected entry has its own override list. Policy findings use
policies.override. Someone on a matching list can apply anyway:
teriac apply -p live/prod/db --override--overrideskips only the entries whose list includes you. Being on the security team's policy list doesn't get you past an SRE freeze.- An entry without an
overridelist can't be skipped by anyone. - When something is still blocking, the reply says who can override it.
- Every use is audited.
Plans built on Terragrunt mock_outputs can never be applied, not even with --override. For a
true emergency, see emergency access.
Leaked-secret scan
Before any output reaches a PR comment, a check run or the dashboard, teriac looks for things that look like secrets:
- AWS access keys and private keys
- GitHub, GitLab, Slack and npm tokens, and Slack webhooks
- Google API keys, Stripe keys, JWTs and Azure storage keys
password = "…"in plan lines
Terraform's own (sensitive value) is left alone.
secret_scan:
mode: redact # redact (default) | fail | off
patterns: # your own, optional
- { name: acme-token, regex: "acme_[0-9a-f]{32}" }| Mode | What happens |
|---|---|
redact (default) |
Each match is replaced with ‹redacted: aws-access-key›, and a warning is added |
fail |
Also refuses the plan, so it can't be applied until the secret is gone |
off |
No scan |
A repo entry can raise redact to fail, never lower it. Every find is audited. The scan covers plan,
apply and command output.
Putting it together
A production setup might look like this:
risk:
production: ["live/prod/**"]
extra_approval:
high: { from: team:sre, count: 1 } # high and critical
critical: { from: team:sre, count: 2 }
freezes:
- { name: weekend, projects: ["live/prod/**"], schedule: "0 15 * * 5", hours: 63, timezone: Europe/Berlin, override: [team:sre] }
protected:
- { projects: ["live/prod/**"], resources: ["aws_db_instance.*", "aws_kms_key.*"], override: [team:sre] }
- { resources: ["aws_s3_bucket.audit_logs"] }
secret_scan:
mode: fail
policies:
mode: enforce
builtin: [security]
override: [team:security]Every key is also in the repos.yaml reference.