Distribuée
Cloud & AI Security
← All insights

Cloud posture

Golden AMIs: building, distributing and enforcing hardened EC2 images in production

A complete Golden AMI pipeline: pinned base, hardening with EC2 Image Builder, publishing via SSM Parameter, and blocking every other image with SCPs, Allowed AMIs and AWS Config.

· 11 min · #golden-ami#ec2#image-builder#scp#aws-config#inspector#hardening#aws

In most of the organizations I audit, the question “where does this instance’s AMI come from?” has no simple answer. A data "aws_ami" with most_recent = true and a name filter, a community AMI found in 2022 and never rebuilt, a Marketplace image whose terms nobody re-reads, three Ubuntu variants depending on the team. It runs, so nobody touches it.

The problem is that the AMI is the starting point of everything that runs on EC2. If it is poorly chosen, unpatched or modified outside any process, the rest of the chain (IAM, network, logging) is protecting a system that is already fragile. A golden AMI is the opposite: an image built by you, from a known base, hardened, tested, versioned, and the only one your accounts are allowed to launch.

This article describes the full pipeline as I set it up on engagements: what goes into the image, how to build it with EC2 Image Builder, how to distribute it, and above all how to forbid everything else. With the Terraform, the SCP and the Config rule that go with it.

Golden AMI pipeline in 5 stages, from pinned base to enforcement via SCP and Config

The threat: what you launch without knowing it

Three scenarios come up every time.

The “most recent” AMI that isn’t yours. In February 2025, Datadog Security Labs documented the whoAMI attack: an attacker publishes a public AMI whose name mimics an official image pattern (ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*). Any code that looks up an AMI by name with most_recent = true without filtering on owners picks it. Datadog found this pattern in production code, including at AWS. An instance launched from that AMI runs whatever the attacker put in it, with the instance’s IAM role.

The instance that is never rebuilt. A 2023 AMI patched “by hand” via SSM Patch Manager is still a 2023 AMI at the next autoscaling event: the new instance boots with the original CVEs, then patches itself, if patching still works. Every scale-out is an exposure window.

Silent drift. Without a common image, the CloudWatch agent is present in prod but not in staging, IMDSv2 is enforced on instances created via the launch template but not on those created from the console, the SSM agent is missing on the one instance that matters. On incident day, you discover that the affected instance was covered by no tool at all.

The control against all three is the same: a single source of approved AMIs, rebuilt regularly, and a technical refusal of everything else.

What a golden AMI must contain

The content varies by context, but the baseline is stable. For an organization on Amazon Linux 2023 or Ubuntu LTS:

ElementWhyHow to prove it
Base pinned by owner (AWS, Canonical) and resolved via SSM public parameterEliminate name confusionExplicit owners in code, no most_recent on a name
All patches at build timeNew instance = 0 known CVEs at bootInspector report per AMI version
CIS Level 1 or STIG hardeningDefensible default configuration in an auditImage Builder test component + report
IMDSv2 required (ImdsSupport = v2.0)Block credential theft via SSRFaws ec2 describe-images shows ImdsSupport: v2.0
SSM, CloudWatch agents, possibly EDRObservability and patching guaranteed from the first secondSSM inventory at 100%
Root volume encrypted with a CMKCompliance, control over who can read snapshotsdescribe-images → Encrypted: true, KmsKeyId
No secrets, SSH keys, default usersThe image will end up shared or copiedFilesystem scan at build
Tags GoldenAmi=true, Version, BuildDate, SourceTag-based enforcement and traceabilityConfig rule approved-amis-by-tag
Deprecation date at D+30Force rotationdescribe-images → DeprecationTime

What you don’t put in it: application code. The golden AMI is a system layer. The application arrives through deployment (container, artefact, user data); otherwise you rebuild an image at every release and the pipeline becomes a bottleneck.

Building with EC2 Image Builder

EC2 Image Builder is free (you pay for the build instance and storage), integrates with Organizations for distribution, and provides useful AWS-managed components: update-linux, amazon-cloudwatch-agent-linux, stig-build-linux-medium, inspector-test-linux. Packer does the job very well too; Image Builder has the advantage of not requiring a CI runner with broad EC2 permissions.

The pipeline in Terraform, reduced to the essentials. Dedicated shared-services account (here 111122223333), region eu-west-3.

locals {
  org_arn      = "arn:aws:organizations::111122223333:organization/o-exampleorg"
  parent_image = "arn:aws:imagebuilder:eu-west-3:aws:image/amazon-linux-2023-x86/x.x.x"
}

# Custom component: additional hardening + checks
resource "aws_imagebuilder_component" "hardening" {
  name     = "org-hardening"
  platform = "Linux"
  version  = "1.3.0"
  data = yamlencode({
    schemaVersion = 1.0
    phases = [
      {
        name = "build"
        steps = [
          {
            name   = "DisablePasswordAuth"
            action = "ExecuteBash"
            inputs = { commands = [
              "sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config",
              "sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config",
            ] }
          },
          {
            name   = "RemoveDefaultKeysAndHistory"
            action = "ExecuteBash"
            inputs = { commands = [
              "rm -f /home/*/.ssh/authorized_keys /root/.ssh/authorized_keys",
              "find / -name '.bash_history' -delete 2>/dev/null || true",
              "rm -f /etc/machine-id && touch /etc/machine-id",
            ] }
          },
        ]
      },
      {
        name = "validate"
        steps = [{
          name   = "NoSecretsOnDisk"
          action = "ExecuteBash"
          inputs = { commands = [
            "! grep -rIE 'AKIA[0-9A-Z]{16}|-----BEGIN (RSA|OPENSSH) PRIVATE KEY' /etc /home /root /opt 2>/dev/null",
          ] }
        }]
      },
      {
        name = "test"
        steps = [{
          name   = "SshHardened"
          action = "ExecuteBash"
          inputs = { commands = [
            "sshd -T | grep -q 'passwordauthentication no'",
            "sshd -T | grep -q 'permitrootlogin no'",
          ] }
        }]
      },
    ]
  })
}

resource "aws_imagebuilder_image_recipe" "golden" {
  name         = "golden-al2023"
  version      = "1.3.0"
  parent_image = local.parent_image

  block_device_mapping {
    device_name = "/dev/xvda"
    ebs {
      volume_size           = 20
      volume_type           = "gp3"
      encrypted             = true
      kms_key_id            = aws_kms_key.ami.arn
      delete_on_termination = true
    }
  }

  # AWS-managed components, pinned by version in real life
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/update-linux/x.x.x" }
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/stig-build-linux-medium/x.x.x" }
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/amazon-cloudwatch-agent-linux/x.x.x" }
  component { component_arn = aws_imagebuilder_component.hardening.arn }
  component { component_arn = "arn:aws:imagebuilder:eu-west-3:aws:component/inspector-test-linux/x.x.x" }

  systems_manager_agent { uninstall_after_build = false }
}

resource "aws_imagebuilder_infrastructure_configuration" "build" {
  name                          = "golden-build"
  instance_profile_name         = aws_iam_instance_profile.imagebuilder.name
  instance_types                = ["t3.medium"]
  subnet_id                     = var.build_subnet_id
  security_group_ids            = [aws_security_group.build_egress_only.id]
  terminate_instance_on_failure = true

  instance_metadata_options {
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  logging {
    s3_logs {
      s3_bucket_name = aws_s3_bucket.imagebuilder_logs.id
      s3_key_prefix  = "golden-al2023"
    }
  }
}

resource "aws_imagebuilder_distribution_configuration" "golden" {
  name = "golden-al2023-dist"

  distribution {
    region = "eu-west-3"

    ami_distribution_configuration {
      name       = "golden-al2023-{{ imagebuilder:buildDate }}"
      kms_key_id = aws_kms_key.ami.arn
      ami_tags = {
        GoldenAmi = "true"
        Source    = "imagebuilder/golden-al2023"
        Version   = "1.3.0"
        BuildDate = "{{ imagebuilder:buildDate }}"
      }
      launch_permission {
        organization_arns = [local.org_arn]
      }
    }

    # The current AMI is exposed to consumers through an SSM parameter
    ssm_parameter_configuration {
      parameter_name = "/golden/al2023/x86_64/latest"
      data_type      = "aws:ec2:image"
    }
  }
}

resource "aws_imagebuilder_image_pipeline" "golden" {
  name                             = "golden-al2023"
  image_recipe_arn                 = aws_imagebuilder_image_recipe.golden.arn
  infrastructure_configuration_arn = aws_imagebuilder_infrastructure_configuration.build.arn
  distribution_configuration_arn   = aws_imagebuilder_distribution_configuration.golden.arn
  enhanced_image_metadata_enabled  = true

  image_tests_configuration {
    image_tests_enabled = true
    timeout_minutes     = 60
  }

  schedule {
    # Every Monday 03:00 UTC, only if a dependency changed (base image or component)
    schedule_expression                = "cron(0 3 ? * MON *)"
    pipeline_execution_start_condition = "EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE"
  }
}

Three points that make the difference in an audit:

  • launch_permission.organization_arns shares the AMI with the whole organisation without ever making it public. An accidentally public AMI is a reportable incident.
  • ssm_parameter_configuration updates /golden/al2023/x86_64/latest at every build. Consumers no longer have hard-coded AMI IDs.
  • inspector-test-linux runs Amazon Inspector on the build image. If critical findings come out, the pipeline fails and the AMI is not distributed. That’s the “0 critical CVEs at build” evidence you’ll show the auditor.

One last step Terraform doesn’t cover cleanly yet: enforcing IMDSv2 at the image level, so that even a hand-created instance inherits the right setting.

AMI_ID=$(aws ssm get-parameter --name /golden/al2023/x86_64/latest --query Parameter.Value --output text)
aws ec2 modify-image-attribute --image-id "$AMI_ID" --imds-support v2.0
aws ec2 enable-image-deprecation --image-id "$AMI_ID" \
  --deprecate-at "$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"

These two commands live in a Lambda triggered by the EventBridge event EC2 Image Builder Image State Change (state AVAILABLE). The AMI deprecates itself 30 days after creation: it disappears from default describe-images results, which cleanly breaks any code that doesn’t follow the SSM parameter.

Consuming: never a hard-coded AMI ID

On the team side, the rule is simple: the AMI comes from the SSM parameter, full stop.

data "aws_ssm_parameter" "golden" {
  name = "/golden/al2023/x86_64/latest"
}

resource "aws_launch_template" "app" {
  name_prefix = "app-"
  image_id    = data.aws_ssm_parameter.golden.value

  metadata_options {
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  update_default_version = true
}

resource "aws_autoscaling_group" "app" {
  # ...
  launch_template {
    id      = aws_launch_template.app.id
    version = "$Latest"
  }

  instance_refresh {
    strategy = "Rolling"
    preferences { min_healthy_percentage = 90 }
  }
}

Launch templates also accept image_id = "resolve:ssm:/golden/al2023/x86_64/latest" directly: the ID is resolved at launch, without terraform apply. Handy for ASGs, less so for traceability (the Terraform plan no longer shows the AMI change). I prefer the first form in prod, the second in dev.

With instance_refresh, each new golden AMI triggers a rolling replacement of the ASG’s instances at the next apply. That’s what turns “we have a new image” into “the fleet is up to date”.

Enforcing: refuse anything that isn’t golden

Building a golden AMI without forbidding the others is writing a policy nobody reads. Three complementary mechanisms.

1. SCP: deny at RunInstances

An SCP on the Workloads OU denies launching any instance whose image is not owned by the shared-services account. The ec2:Owner condition applies to the image resource.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "OnlyGoldenAmis",
      "Effect": "Deny",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:*::image/ami-*",
      "Condition": {
        "StringNotEquals": {
          "ec2:Owner": "111122223333"
        }
      }
    }
  ]
}

Result: RunInstances with a community, Marketplace or hand-built AMI in the account fails with UnauthorizedOperation. Legitimate Marketplace appliances (a firewall, a vendor bastion) are handled as exceptions: there is no second Allow statement in an SCP, so either you place them in a dedicated OU without this SCP, or you add their owner to the ec2:Owner list. Document every exception; the auditor will ask for the list.

2. Allowed AMIs: the native EC2 guardrail

Since late 2024, EC2 offers an account-level Allowed AMIs setting that restricts discovery and launch to images from approved providers (amazon, aws-marketplace, aws-backup-vault or account IDs). It has an audit mode that blocks nothing but flags non-compliant AMIs in describe-images (ImageAllowed: false), so you can measure the impact before enabling it.

# Phase 1: measure
aws ec2 enable-allowed-images-settings --allowed-images-settings-state audit-mode
aws ec2 replace-image-criteria-in-allowed-images-settings \
  --image-criteria "ImageProviders=111122223333"

# What it would block today
aws ec2 describe-instances \
  --query "Reservations[].Instances[].[InstanceId,ImageId]" --output text \
  | while read id ami; do
      allowed=$(aws ec2 describe-images --image-ids "$ami" \
        --query "Images[0].ImageAllowed" --output text 2>/dev/null)
      [ "$allowed" = "False" ] && echo "NON-COMPLIANT $id $ami"
    done

# Phase 2: block
aws ec2 enable-allowed-images-settings --allowed-images-settings-state enabled

The setting is per account and per region. To roll it out across the organisation in one go, it goes through Organizations declarative policies (type EC2), which cover precisely this setting. It’s more readable than an SCP and can’t be bypassed through a forgotten API path.

3. AWS Config: detect drift

The SCP blocks the future. Config finds the present: instances launched before the rule, or in an account outside the OU.

resource "aws_config_config_rule" "approved_amis" {
  name = "approved-amis-by-tag"
  source {
    owner             = "AWS"
    source_identifier = "APPROVED_AMIS_BY_TAG"
  }
  input_parameters = jsonencode({ amisByTagKeyAndValue = "GoldenAmi:true" })
}

resource "aws_config_config_rule" "imdsv2" {
  name = "ec2-imdsv2-check"
  source {
    owner             = "AWS"
    source_identifier = "EC2_IMDSV2_CHECK"
  }
}

Deployed as an organization conformance pack from the audit account, these two rules give a per-account dashboard: how many instances run on something other than a golden AMI, how many still accept IMDSv1. That’s the engagement’s starting number, and the one you drive to zero.

Evidence for the auditor

This pipeline produces, with no extra effort, what a SOC2 or ISO 27001 auditor or an enterprise customer asks for:

  • Provenance: Image Builder logs in S3, recipe and components versioned in Git, Source and BuildDate in every AMI’s tags.
  • Vulnerabilities: Inspector report per build, pipeline failure on critical finding, Inspector findings on running instances correlated with the AMI version.
  • Hardening: STIG or CIS component applied and tested, settings verified in the test phase.
  • Enforcement: versioned SCP, Allowed AMIs setting enabled, Config compliance at 100% or a named list of exceptions.
  • Rotation: DeprecationTime on every AMI, no instance with an AMI older than 30 to 45 days (Config Advanced Query on imageId × creationDate).

What makes the difference in an audit isn’t having a golden AMI. It’s being able to show that launching any other one is impossible, and proving it with today’s data rather than a policy document.

Conclusion

The golden AMI is one of the few controls that improves security (reduced surface, guaranteed patches, IMDSv2 everywhere), operability (one image to know, agents always present) and compliance (evidence generated by the pipeline) at the same time. The cost is low: a t3.medium for a few minutes a week, a shared account, three more Terraform resources. The real work is in enforcement, because that’s what exposes the exceptions and forces you to document them.

If your instances run today on AMIs whose provenance nobody knows, or if your code still contains a most_recent = true without owners, it’s one of the first things we look at in a Cloud Security Audit: the inventory of images in production takes an hour and gives a very precise picture of the real posture.

Found this useful? Share it.

Go further

A topic, a project, a question?

Distribuée supports demanding SMBs on cloud security, CI/CD supply chain and AI security.

Book 15 min