Keep reports in a bucket
Optional: hand this page to your coding agentThe steps work by hand too.Show the whole prompt
Read https://intentius.io/terragucci/guides/keep-reports-in-a-bucket/.
Add the `reports:` block for bucket <bucket>, rerun `npx terragucci init`, write
the access policy the page gives for my cloud as a file for me to review, and open a pull request.
Do not create the role, the bucket, the container, any secret or the front door stack.
Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.Result
Section titled “Result”Every run’s report in a bucket under one path per run, and an index.html that lists every plan you have run.
Prerequisites
Section titled “Prerequisites”| Cloud | Store | Identity the plan job writes with |
|---|---|---|
| AWS | an S3 bucket, or an S3-compatible store such as R2 or MinIO | reports.role over OIDC, or keys |
| GCP | a Cloud Storage bucket, through its JSON API | the plan service account of oidc.gcp |
| Azure | a Blob Storage container | the plan client of oidc.azure, or the account key |
OIDC sets up the GCP and Azure identities.
-
Name the bucket in
terragucci.yml.reports:bucket: s3://acme-terragucciprefix: reportsurl: https://reports.acme.examplerole: arn:aws:iam::123456789012:role/terragucci-reportsFor a store that is not AWS, add
endpoint; without it, the job readsAWS_ENDPOINT_URL_S3orAWS_ENDPOINT_URL.Requests are signed for
AWS_REGIONorAWS_DEFAULT_REGION, elseus-east-1. To sign for another region, set it in the config’senv:env:AWS_REGION: eu-west-2reports:bucket: gs://acme-terragucciprefix: reportsendpointdefaults tohttps://storage.googleapis.com; an emulator sets its own.reports:bucket: az://acmeterragucci/reportsprefix: reportsThe bucket is
az://<storage account>/<container>. Setendpointfor a sovereign cloud or an emulator; it defaults tohttps://<account>.blob.core.windows.net.urlis where a browser opens the reports, such as the front door (step 5). The plan note linksreport.htmland each root’splan.txtthere. Withouturlthe note uses presigned bucket links that last up to 7 days. -
Regenerate the pipeline and commit it.
Terminal window npx terragucci init -
Give the job an identity that writes only reports.
The plan job runs the pull request’s code, and that code can read its credentials. Limit this identity to read and write under the prefix.
Allow only
s3:GetObjectands3:PutObjectonarn:aws:s3:::acme-terragucci/reports/*.With
oidcset,reports.roleis that identity, assumed through STSAssumeRoleWithWebIdentityunder a trust policy like your plan role’s.config checkrefuses areports.rolethat is the plan or apply role.Without
oidc, give an IAM user the same policy and store its keys as secrets.initpasses them only to the jobs that plan, where OpenTofu sees them too.Secret Needed AWS_ACCESS_KEY_IDalways AWS_SECRET_ACCESS_KEYalways AWS_SESSION_TOKENwhen the keys are temporary Order Identity 1 reports.role2 AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY3 AWS_ROLE_ARN, which a job withoidcsets to its own role; that role then needs write access to the reportsA role is assumed with the token in
AWS_WEB_IDENTITY_TOKEN_FILE;AWS_ENDPOINT_URL_STSorAWS_ENDPOINT_URLsets the STS address. When STS refuses it, the job fails with STS’s error.Writes go through
oidc.gcp.plan_service_account, using theexternal_accountfile inGOOGLE_APPLICATION_CREDENTIALS.Grant to the plan service account On For roles/storage.objectUser, with an IAM condition on thereports/prefixthe bucket reading and writing reports roles/iam.serviceAccountTokenCreatorthe service account itself signing the estate page’s link through IAM signBlobA
service_accountkey file inGOOGLE_APPLICATION_CREDENTIALSworks too: requests carry a JWT it signs, and links are signed with the key.The job writes as
oidc.azure.plan_client_id, trading its token inARM_OIDC_TOKEN_FILE_PATHat Entra ID for a storage token.Assign to the plan client Scope For Storage Blob Data Contributor the container reading and writing reports Storage Blob Delegator the storage account the user delegation key that signs a link AZURE_AUTHORITY_HOSTnames Entra ID outside the public cloud, such ashttps://login.microsoftonline.us. Withoutoidc.azure, store the account key as the secretAZURE_STORAGE_KEY;initmaps it to the jobs that plan. An account key reaches every container in the account.Store them as repository secrets.
Use unprotected CI/CD variables.
Repository secrets, as on GitHub.
Even so, a pull request’s plan can overwrite any object under the prefix, including the index. The apply job’s CI artifact is the record of what was applied.
-
Open a pull request. When the plan job finishes, its log says where the report went:
report: terragucci-report/report.htmlcopied to the bucket under reports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-plan; index rewritten at reports/github.com/acme/infra/index.json and reports/index.jsonserved at https://reports.acme.example/reports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-plan/report.htmlEach run gets one path holding the commit’s full SHA. Links inside a report are relative:
reports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-plan/report.htmlreports/github.com/acme/infra/2026/10/4f1a9c0d2e7b8a6c5d4e3f2a1b0c9d8e7f6a5b4c/tf-apply-wave-2/report.jsonForge Commit in the path GitHub GITHUB_SHA, the merge commit on a pull requestGitLab CI_COMMIT_SHAForgejo GITHUB_SHA -
Open the reports.
Every upload adds the run’s row to two
index.jsonfiles, one at the project’s path and one at the top of the prefix, and writesindex.htmlfrom each. Atf-applywave also writes these at the project’s path:File Holds inventory.jsonthe resources each applied root holds changes.jsonwhat the wave did to each resource states.jsonthe version of each applied root’s state Writes are retried up to 8 times:
Store Index write S3, Azure Blob conditional: If-Matchthe ETag read, orIf-None-Match: *GCS conditional: ifGenerationMatchthe generation read, or0An S3-compatible store with no ETag plain write, last run wins An S3-compatible store that answers 501 Not Implementedthe condition is dropped The bucket holds every plan, including each attribute value not marked sensitive. Keep it private and open it one of two ways:
Presigned link Front door You deploy nothing one CloudFormation stack in your account, in us-east-1; AWS onlyWho can open it anyone holding the link, until it expires whoever your identity provider signs in What opens one object; its links to other reports need links of their own every report and index, with their links reports.urlnot set: the plan note links report.htmland eachplan.txt, presignedhttps://<DomainName>Sign one object for an hour:
Cloud Command Longest life AWS aws s3 presign s3://acme-terragucci/reports/github.com/acme/infra/index.html --expires-in 36007 days with an IAM user’s keys; a role’s session ends sooner GCP gcloud storage sign-url gs://acme-terragucci/reports/github.com/acme/infra/index.html --duration 1h --impersonate-service-account <service account>7 days Azure az storage blob generate-sas --account-name acmeterragucci --container-name reports --name reports/github.com/acme/infra/index.html --permissions r --expiry <time> --auth-mode login --as-user --full-uri7 days terragucci estateprints such a link to its page on each cloud.Deploy the template in The front door below and set
urlto its address:reports:url: https://reports.acme.exampleA run that sent a trace also gets
traces/<trace id>.htmlunder the prefix, forwarding to its report. Withdashboards: true, the Runs dashboard links it.The reports bucket lists every key under the prefix and the JSON Schema of each object.


Retention is your bucket’s lifecycle rule.
The front door
Section titled “The front door”The template puts CloudFront and an OpenID Connect sign-in with a signed session cookie in front of the bucket. It runs in your account and talks to nothing of terragucci’s.
First register a web application with your identity provider. Give it the redirect URI https://reports.acme.example/_terragucci/callback and the openid and email scopes. Any provider whose token endpoint takes client_secret_post works; Google, Microsoft Entra ID and Okta do.
-
Store the client secret in Secrets Manager, in
us-east-1:Terminal window aws secretsmanager create-secret --region us-east-1 \--name terragucci-reports-client --secret-string '<client secret>' -
Deploy the template:
Terminal window curl -fsSLO https://intentius.io/terragucci/reports-front-door.jsonaws cloudformation deploy --region us-east-1 --stack-name terragucci-reports \--template-file reports-front-door.json --capabilities CAPABILITY_IAM \--parameter-overrides ReportsBucket=acme-terragucci ReportsBucketRegion=us-east-1 \DomainName=reports.acme.example HostedZoneId=Z0123456789ABCDEFGHIJ \OidcIssuer=https://accounts.google.com OidcClientId=<client id> \OidcClientSecretName=terragucci-reports-client AllowedEmailDomain=acme.example -
Let the distribution read the bucket by adding the statement in the stack’s
BucketPolicyStatementoutput to the bucket’s policy:Terminal window aws cloudformation describe-stacks --region us-east-1 --stack-name terragucci-reports \--query "Stacks[0].Outputs[?OutputKey=='BucketPolicyStatement'].OutputValue" --output textWriteBucketPolicy=truewrites it instead for a bucket with no policy of its own. A bucket encrypted with your own KMS key also needskms:Decryptforcloudfront.amazonaws.comunder the sameAWS:SourceArncondition. -
Set
url: https://reports.acme.exampleunderreports:and runnpx terragucci initagain.
| Parameter | Default | Meaning |
|---|---|---|
ReportsBucket |
required | reports.bucket without s3:// |
ReportsBucketRegion |
us-east-1 |
the bucket’s region |
DomainName |
required | the name people open |
HostedZoneId |
empty | the Route 53 zone of DomainName, for its A and AAAA alias records and the certificate’s validation; empty writes no record |
CertificateArn |
empty | an ACM certificate in us-east-1 for DomainName; empty requests one |
OidcIssuer |
required | such as https://accounts.google.com or https://login.microsoftonline.com/<tenant>/v2.0 |
OidcClientId |
required | the web application’s client id |
OidcClientSecretName |
required | the secret from step 1 |
AllowedEmailDomain |
empty | only verified addresses at this domain get in; empty admits anyone the issuer signs in |
SessionHours |
12 |
how long a sign-in lasts |
WriteBucketPolicy |
false |
true writes the bucket policy |
| The stack makes | Named |
|---|---|
| CloudFront distribution, caching nothing, HTTPS only | DistributionId output |
| Origin Access Control, signing every request to the bucket | the stack name |
| Lambda@Edge function on every viewer request, and its role | the stack name |
| Secrets Manager secret with the settings and a generated session key | the stack name |
ACM certificate, when CertificateArn is empty |
its ARN |
Route 53 A and AAAA alias records, when HostedZoneId is set |
DomainName |
A request without a session cookie goes to the identity provider and then back to its report. /_terragucci/logout clears the session. A parameter change reaches the edge within five minutes.
Deleting the stack can fail on the function while CloudFront removes its copies, which takes a few hours; delete it again after that.
- The plan report shows what a report holds.
- Report JSON schema is the format a script reads from the bucket.
These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.