Your Terraform apply succeeded, your tests passed green, and three days later a security audit flags an unencrypted S3 bucket that was sitting in production the whole time — that gap is a compliance-shaped hole in your CI pipeline. An AWS Config CI compliance gate is how you close it, but the moment you go looking for a solution, you hit a fork in the road: do you evaluate actual deployed state via AWS Config rule polling, or do you catch violations before any AWS API call fires using policy-as-code tools like cfn-guard? The answer matters more than most teams realize, and picking the wrong one for your context will either slow your pipeline to a crawl or give you a false sense of security.
When You Face This Choice

The trigger is almost always the same. A misconfigured S3 bucket slips through a terraform apply because nothing in the pipeline was checking compliance — only syntax and plan validity. Or an overly permissive IAM inline policy makes it into a staging account and stays there for two weeks until someone runs a manual Config evaluation. The security team files a finding, the post-mortem asks “why didn’t CI catch this,” and suddenly you’re tasked with adding a compliance gate to the pipeline.
Two realistic options emerge. Option A uses AWS Config managed or custom rules as a post-deploy blocking signal: you apply your infrastructure, then poll aws configservice get-compliance-details-by-config-rule until the evaluation completes or a timeout fires. Option B runs policy-as-code tools — specifically cfn-guard or OPA/Conftest — directly inside the CI job against your IaC artifacts before a single AWS API call is made.
Both can block a merge. But they differ fundamentally in when feedback arrives, what AWS permissions your CI runner needs, and critically — whether they catch drift from manual console changes. Those differences determine which one belongs in your pipeline.
Option A: AWS Config Rules as CI Gates
The mechanics are straightforward in theory. After terraform apply or a CloudFormation deploy to a staging account, the pipeline polls Config until the rule evaluation finishes or a timeout fires. A NON_COMPLIANT result fails the stage and blocks promotion to production.
In practice, you call something like this in a polling loop:
aws configservice get-compliance-details-by-config-rule \
--config-rule-name s3-bucket-public-read-prohibited \
--compliance-types NON_COMPLIANT \
--query 'EvaluationResults[].ComplianceType' \
--output text
If the output is empty, the resource is compliant. If it returns NON_COMPLIANT, you fail the pipeline.
Pros: This evaluates actual deployed state, not just IaC intent. It reuses AWS managed rules your organization already mandates via Service Control Policies — rules like s3-bucket-public-read-prohibited, iam-no-inline-policy, and encrypted-volumes require zero custom logic to write. For regulated environments, Config evaluation records are a concrete audit artifact that auditors accept as evidence of evaluated deployed state. That matters for PCI-DSS and FedRAMP assessments in ways that a local policy check simply cannot replicate.
Cons: Evaluation latency is the killer. Change-triggered rules return results in 2–8 minutes. Periodic rules can take up to 24 hours — completely useless as a synchronous CI gate. You also need a live staging AWS account with Config enabled, which costs real money: at $0.003 per configuration item and $0.001 per rule evaluation, 50 rules across 500 deploys per month lands you in the $25–$75/month range for a busy staging account. That’s not enormous, but it adds up across multiple teams.
The IAM surface area is a real concern. The CI role needs at minimum config:GetComplianceDetailsByConfigRule, config:DescribeConfigRules, and config:ListDiscoveredResources. Teams routinely skip scoping the Resource condition to specific rule ARNs, leaving a broad permission set on the runner role. If that token is compromised, an attacker can enumerate your entire compliance posture.
Watch out for this: the iam-no-inline-policy managed rule has a documented evaluation gap — inline policies attached via CDK’s addToPolicy() can lag 15–30 minutes after deploy before the backing Lambda re-evaluates. You can pass your Config gate and still have a non-compliant resource in flight.
Option B: Pre-Deploy Policy-as-Code in CI
cfn-guard v3.1.1 (latest stable as of Q1 2025) runs against your CloudFormation template or CDK-synthesized output as a pure local step. No AWS credentials. No deployed resources. Sub-5-second feedback on a warm runner.
Here is a cfn-guard rule file that enforces three S3 controls — public access blocking, encryption, and versioning for data-tier buckets:
# policy/guard-rules/s3_compliance.guard
# cfn-guard 3.1.1 — validates S3 buckets in CDK/CloudFormation templates
# Run: cfn-guard validate -d cdk.out/MyStack.template.json -r policy/guard-rules/
# Rule 1: All S3 buckets must block public access on all four settings
rule s3_block_public_access_enabled {
AWS::S3::Bucket {
# Check the PublicAccessBlockConfiguration block exists and all flags are true
PublicAccessBlockConfiguration {
BlockPublicAcls == true <<Bucket must have BlockPublicAcls set to true>>
BlockPublicPolicy == true <<Bucket must have BlockPublicPolicy set to true>>
IgnorePublicAcls == true <<Bucket must have IgnorePublicAcls set to true>>
RestrictPublicBuckets == true <<Bucket must have RestrictPublicBuckets set to true>>
}
}
}
# Rule 2: All S3 buckets must have server-side encryption configured
rule s3_encryption_required {
AWS::S3::Bucket {
BucketEncryption {
ServerSideEncryptionConfiguration[*] {
ServerSideEncryptionByDefault {
# Allow AES256 or aws:kms — block unencrypted buckets
SSEAlgorithm in ["AES256", "aws:kms"]
<<Bucket encryption must use AES256 or aws:kms — ref: SEC-004>>
}
}
}
}
}
# Rule 3: Versioning must be enabled on buckets tagged as "data-tier"
rule s3_versioning_for_data_tier when
AWS::S3::Bucket {
Tags[*] {
Key == "tier"
Value == "data"
}
}
{
AWS::S3::Bucket {
VersioningConfiguration.Status == "Enabled"
<<Data-tier buckets require versioning — ref: RES-012>>
}
}
Pros: Fails fast. Engineers get compliance feedback at PR time, not after a 10-minute deploy cycle. Because no AWS credentials are required, this runs safely on PRs from public forks — a meaningful advantage for open-source infrastructure repos or teams using GitHub’s fork-based contribution model. Rules live in version control alongside the IaC code, so every change is auditable and testable. The --show-summary all flag introduced in v3.1.1 gives you clean pass/fail counts without dumping the full verbose rule tree.
Cons: It evaluates declared intent, not deployed reality. A manual console change that creates a non-compliant resource is completely invisible to cfn-guard. A terraform import of an existing non-compliant resource bypasses it entirely. The rule DSL has a steep learning curve, and error messages are notoriously unhelpful — [FAILED] Rule s3_encryption_check with no line number reference makes debugging painful for contributors who didn’t write the rules.
Watch out for this: cfn-guard exit code 5 means rules passed but warnings exist. Many pipelines treat any non-zero exit as a failure, which will block on warnings you intended to allow. Set --fail-on-warnings false explicitly, or you’ll spend an afternoon debugging a pipeline that fails for the wrong reason.
Also — if you’re using CDK, point cfn-guard at cdk.out/MyStack.template.json, not cdk.out/manifest.json. The error Error parsing template: expected a mapping is cfn-guard telling you it got a CDK asset manifest instead of an actual CloudFormation template. I’ve seen this waste hours on first-time setups.
Decision Matrix
Here is how the two options map across the dimensions that actually matter in a real pipeline decision:
| Dimension | Option A — Config Polling | Option B — cfn-guard |
|---|---|---|
| Feedback speed | 2–15 minutes (change-triggered rules) | Under 5 seconds |
| AWS account required | Yes — staging with Config enabled | No |
| Catches drift / console changes | Yes | No |
| Rule maintenance burden | Low — reuse managed rules | Medium — custom DSL authoring |
| CI runner permissions needed | config:Get*, config:Describe*, config:List* | None |
| Works on fork PRs | No — credentials required | Yes |
| Cost at 500 deploys/month | $25–$75/month | $0 |
| Audit artifact for regulators | Yes — Config evaluation records | No |
The hybrid pattern is worth calling out explicitly: use Option B as the PR gate for fast feedback, and Option A as the post-deploy gate on the staging promotion step. This is the pattern used in AWS’s own Landing Zone Accelerator pipeline — cfn-guard catches intent violations early, Config polling catches drift and provides the audit trail. You get both speed and deployed-state verification.
Team size matters here. Solo engineers and small teams under five people rarely justify the operational overhead of Config polling as a synchronous gate. Platform teams managing multi-account organizations almost certainly have Config enabled already and should exploit it. If you’re already paying for Config, the marginal cost of adding a polling step is low and the audit benefit is real.
My Pick
I prefer Option B — cfn-guard as the primary AWS Config CI compliance gate — with Config rules reserved for drift detection and compliance dashboards, not as a synchronous pipeline blocker. Here is the complete GitHub Actions workflow that implements this pattern:
# .github/workflows/compliance-gate.yml
# Runs cfn-guard (pre-deploy) and optionally polls AWS Config (post-deploy)
# Requires: cfn-guard 3.1.1, AWS CLI v2, Terraform 1.7+
name: Compliance Gate
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
GUARD_VERSION: "3.1.1"
AWS_REGION: "us-east-1"
STAGING_ACCOUNT_ID: ${{ secrets.STAGING_ACCOUNT_ID }}
jobs:
pre-deploy-policy-check:
name: "Option B — cfn-guard pre-deploy gate"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
# Cache cfn-guard binary to avoid 18-25s cold-start penalty on each run
- name: Cache cfn-guard binary
uses: actions/cache@v4
with:
path: ~/.local/bin/cfn-guard
key: cfn-guard-${{ env.GUARD_VERSION }}
- name: Install cfn-guard if not cached
run: |
if ! command -v cfn-guard > /dev/null; then
curl -sL \
"https://github.com/aws-cloudformation/cloudformation-guard/releases/download/${GUARD_VERSION}/cfn-guard-v3-ubuntu-latest.tar.gz" \
| tar -xz -C ~/.local/bin/
chmod +x ~/.local/bin/cfn-guard
fi
- name: Synthesize CDK templates
run: |
npm ci
npx cdk synth --output cdk.out
# Produces cdk.out/MyStack.template.json — NOT manifest.json
- name: Run cfn-guard against all synthesized templates
run: |
cfn-guard validate \
--data cdk.out/ \
--rules policy/guard-rules/ \
--show-summary all \
--fail-on-warnings false \
--output-format json 2>&1 | tee guard-results.json
# Exit code 5 = warnings only (allowed); exit code 2 = rule failures (blocked)
EXIT_CODE=${PIPESTATUS[0]}
if [ "$EXIT_CODE" -eq 2 ]; then
echo "::error::cfn-guard found NON_COMPLIANT resources. See guard-results.json"
exit 1
fi
- name: Upload compliance artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: guard-results
path: guard-results.json
post-deploy-config-poll:
name: "Option A — AWS Config post-deploy gate (staging only)"
runs-on: ubuntu-22.04
needs: [pre-deploy-policy-check]
# Only run on pushes to main (after merge), not on PRs — avoids fork permission issues
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC — scoped read-only Config role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ env.STAGING_ACCOUNT_ID }}:role/ci-config-readonly
aws-region: ${{ env.AWS_REGION }}
- name: Deploy to staging
run: |
terraform init
terraform apply -auto-approve -var="env=staging"
- name: Poll AWS Config with exponential backoff
run: |
RULES=("s3-bucket-public-read-prohibited" "iam-no-inline-policy" "encrypted-volumes")
MAX_ATTEMPTS=8
for RULE in "${RULES[@]}"; do
ATTEMPT=0
SLEEP=15
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
RESULT=$(aws configservice get-compliance-details-by-config-rule \
--config-rule-name "$RULE" \
--compliance-types NON_COMPLIANT \
--query 'EvaluationResults[].ComplianceType' \
--output text)
if [ -z "$RESULT" ]; then
echo "Rule $RULE: COMPLIANT"
break
elif [ "$ATTEMPT" -eq $((MAX_ATTEMPTS - 1)) ]; then
echo "::error::Rule $RULE returned NON_COMPLIANT after $MAX_ATTEMPTS attempts"
exit 1
fi
echo "Waiting ${SLEEP}s for Config evaluation (attempt $((ATTEMPT+1))/$MAX_ATTEMPTS)..."
sleep $SLEEP
SLEEP=$((SLEEP * 2)) # Exponential backoff — avoids hanging on AWS delays
ATTEMPT=$((ATTEMPT + 1))
done
done
My reasoning is simple: the latency and IAM surface area of Config polling introduce more operational risk than they solve for most teams. Policy-as-code in CI is reproducible, testable, fast, and doesn’t depend on AWS service availability. I stopped treating Config as a synchronous gate after we had a 45-minute pipeline freeze during an AWS Config service degradation in us-east-1 — every deploy across three teams was blocked because the polling loop hit its max attempts with no result.
I will flip this recommendation for one specific context: regulated environments where auditors require evidence of evaluated deployed state. PCI-DSS and FedRAMP assessors want to see Config evaluation records, not cfn-guard JSON artifacts. In that case, the Config polling gate is worth every cent and every minute of latency. Build the hybrid pattern, scope the IAM role tightly to specific rule ARNs, and make sure the CI Config role is separate from the deployment role — a compromised deploy token should not be able to enumerate or suppress compliance findings.
For everyone else: ship cfn-guard rules in the same PR as the infrastructure change, keep the feedback loop under 30 seconds, and let Config do what it does best — continuous drift detection and compliance reporting on the infrastructure you’ve already deployed.
