Cloud: EKS Infrastructure and Helm Deployment

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Magento Open Source and Adobe Commerce 2.4.9 (general availability 2026-05-12) and Mage-OS 3.6.0, whose source and Adobe’s documentation were read as cited, with PHP 8.5, OpenTofu 1.12.6 and the AWS provider 6.71 (constraint ~> 6.70) and the terraform-aws-modules registry modules at the versions pinned in the listings, Helm 4.3.1, kubeconform 0.8.0, Kubernetes 1.36 on Amazon EKS (1.37 has been available on EKS since 2026-10-01), Varnish 8.0 and KEDA, and checked on 2026-10-29. The configuration and the chart were validated locally as described in each section and in Version notes and what was not verified; nothing was planned, applied or deployed, and no cloud credentials were used.

This is the page that builds what the rest of the Magento cloud section runs on. Pocket Coffee (a fictitious coffee shop, www.pocketcoffee.example) gets one root module in infra/ that creates the cluster pocketcoffee-prod in eu-west-1 with its data tier (a database, a search engine, two key-value stores and a message broker), and one Helm chart, pocketcoffee-magento, that deploys the release pocketcoffee into the namespace pocketcoffee. The architecture and the reasons for it are on Cloud: Reference Architecture. The details this page only uses are on Cloud: Managed Database (RDS and Aurora), Cloud: OpenSearch, Valkey and RabbitMQ as Managed Services, Cloud: Container Image and Pipeline Build, Cloud: Media and Remote Storage, Cloud: Varnish and Full Page Cache on Kubernetes and Cloud: Cron, Queue Consumers and Indexers. Releases and rollbacks are on Cloud: Releases and Zero-Downtime Deployments, scaling, logs and backups on Cloud: Autoscaling, Observability, Backups and Security, moving an existing shop on Cloud: Migrating from a Single Server and the other clouds on Cloud: Google Cloud, Azure and Alternatives.

The WordPress and PrestaShop sections build the same kind of platform, and their infrastructure pages are the model for this one: same file layout, same modules, same workspaces, same state backend. This page does not repeat the explanation of those; it shows the Magento version of each file and says what differs and why. Read EKS Infrastructure with OpenTofu for the state backend bootstrap, the workspace trade-offs and the secrets-and-state discussion, and the PrestaShop page for the closest comparison. The fundamentals are on Infrastructure as Code on AWS, VPC Networking, EKS Clusters, RDS, OpenSearch on AWS, Caching, Graph and Partner Databases, Messaging and Integration on AWS, S3 Object Storage, Block and File Storage on AWS, Secrets Manager and KMS, Route 53 and Certificates, Container Registry, Load Balancers and Gateways and Deploying Microservices on EKS, and for Kubernetes on Helm, Jobs and CronJobs, Health Probes and Lifecycle, Scheduling and Disruptions, Autoscaling, Secrets and Gateway API. How Magento itself is installed and configured on one server is on Installation, and the settings these files move into the environment are the subject of Caching, Indexing and Performance and CLI, Cron and Message Queues.

What the configuration builds

OpenTofu layout: one root module with an S3 state backend and two workspaces; a VPC with public, private, database and ElastiCache subnets holding EKS, OpenSearch, Amazon MQ, RDS and two Valkey groups; and S3, EFS, ECR, Secrets Manager, KMS, ACM and WAF outside the VPC

One root module in infra/ creates one environment; the workspace (staging or production) chooses which. The files are split by service:

File Contents

versions.tf, variables.tf, locals.tf

Constraints, the S3 backend, two provider blocks (the second is us-east-1, for the CloudFront certificate), the inputs and the per-workspace settings (sizes, CIDR, host names) in one map

vpc.tf, kms.tf

VPC with public, private, database and ElastiCache subnets in three zones; one customer-managed key

eks.tf

EKS Auto Mode cluster, the EFS add-on, the access entry of the deployment role, Pod Identity roles for the EFS driver and External Secrets Operator

rds.tf

RDS for MySQL 8.4 with the parameter group pocketcoffee-magento-mysql84, Multi-AZ in production, with a managed master password

opensearch.tf

Amazon OpenSearch Service domain, VPC access, fine-grained access control with an internal master user

elasticache.tf

Two Valkey replication groups, pocketcoffee-prod-cache and pocketcoffee-prod-session, with different eviction policies

mq.tf

Amazon MQ for RabbitMQ 4.3 broker, a cluster in production

media.tf, efs.tf

The S3 media bucket, the IAM role of the Magento pods bound to the service account with Pod Identity; the EFS alternative

ecr.tf, secrets.tf

Two image repositories and the GitHub OIDC deployment role; the Secrets Manager containers

acm.tf, waf.tf

The regional site certificate and the us-east-1 media certificate, both with DNS validation; a web ACL that restricts the Admin

outputs.tf

The values the chart and the seeding script need

Decisions that differ from the other platforms

  • MySQL 8.4, not MariaDB. RDS for MySQL 8.4 is the engine of this section (standard support until 2029-07-31; 8.0 left standard support on 2026-07-31) and Adobe’s 2.4.9 requirements list MySQL 8.4 and MariaDB 12.3. Aurora MySQL 3 is the one Aurora version that Adobe’s table names (8.0.mysql_aurora.3.12 or latest available); Aurora, RDS Proxy and MariaDB are on Cloud: Managed Database (RDS and Aurora). The split database (separate sales and quote databases) is deprecated since 2.4.2 and nothing here uses it.

  • Five managed services behind one application. Magento needs a search engine (OpenSearch 3), a cache and a session store (Valkey 9), a message broker (RabbitMQ 4.3) and a full page cache (Varnish, which runs in the cluster, because the page cache is not a managed service on AWS). Adobe’s 2.4.9 table has a row per AWS service that is more conservative than the versions of the engines: OpenSearch 3.1 or latest available, AWS MQ 3.13 or latest available and ElastiCache Valkey 8 is available. This page uses what the application tests (OpenSearch 3.5, RabbitMQ 4.3, Valkey 9.0) and makes each version a one-line change; check those rows when you apply.

  • Two Valkey replication groups, not one. Adobe recommends one instance per purpose. The cache group is rebuildable and uses volatile-lru; the session group never evicts (noeviction), keeps a daily snapshot and has replicas in other zones. The eviction choice and what allkeys-lru would do to the tag index of the cache backend are on the OpenSearch, Valkey and RabbitMQ page. There is no Valkey database for the full page cache: Varnish holds it, and env.php has no page_cache frontend.

  • Amazon MQ needs the Graviton family for 4.x. RabbitMQ 4.3 and 4.2 run only on the mq.m7g instance types in Amazon’s documentation, so the single instance of staging uses mq.m7g.large as well.

  • The media bucket always exists, EFS only when asked. The default is remote storage on S3 behind CloudFront, so the pods need an IAM role and no shared volume; media_mode = "efs" creates the file system and its access point instead (see Cloud: Media and Remote Storage for the choice).

  • Two extra WAF rules. The Admin lives under the path of admin_front_name and the REST route that turns an administrator’s password into a token bypasses it, so both are blocked for every address outside an allow-list; the managed rule groups run with one override that the Admin’s large forms require.

  • Secrets are containers. OpenTofu creates the Secrets Manager secrets; a script writes the values (below). The one value that must never pass through the configuration or the state is the encryption key, crypt/key.

State backend and workspaces

The backend is the S3 bucket and the use_lockfile locking of the WordPress page, with the state key magento/pocketcoffee.tfstate, so that all platforms can share one bucket. Create the bucket as shown in the state backend section (use your own bucket name), then pass it with -backend-config. The workspace map in locals.tf refuses default, as there, so a plan in the wrong workspace fails instead of creating a third environment. The honest limits of workspaces (one credential for both environments) are on the same page; separate AWS accounts with the same code are the stricter choice.

The files

Providers, inputs and workspace settings

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.70"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.7"
    }
  }

  # Partial configuration: the bucket is passed with -backend-config.
  backend "s3" {
    key                  = "magento/pocketcoffee.tfstate"
    workspace_key_prefix = "workspaces"
    region               = "eu-west-1"
    encrypt              = true
    use_lockfile         = true
  }
}

provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Project     = "pocketcoffee-magento"
      Environment = local.env
      ManagedBy   = "opentofu"
    }
  }
}

# CloudFront certificates must be requested in us-east-1.
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"

  default_tags {
    tags = {
      Project     = "pocketcoffee-magento"
      Environment = local.env
      ManagedBy   = "opentofu"
    }
  }
}
variable "region" {
  description = "Region of the cluster and of every regional resource."
  type        = string
  default     = "eu-west-1"
}

variable "root_domain" {
  description = "Public Route 53 hosted zone that already exists."
  type        = string
  default     = "pocketcoffee.example"
}

variable "admin_cidrs" {
  description = "CIDR blocks allowed to reach the public Kubernetes API endpoint and the Magento Admin."
  type        = list(string)
  default     = ["203.0.113.0/24"]
}

variable "github_repository" {
  description = "Repository whose workflows may push images to ECR and deploy (OIDC subject)."
  type        = string
  default     = "pocketcoffee/magento-shop"
}

variable "admin_front_name" {
  description = "Admin frontName (env MAGENTO_BACKEND_FRONTNAME), used by the WAF rule that restricts it."
  type        = string
  default     = "admin_pocketcoffee"
}

variable "media_mode" {
  description = "Where pub/media lives: s3 (remote storage) or efs. The bucket always exists; the file system only for efs."
  type        = string
  default     = "s3"

  validation {
    condition     = contains(["s3", "efs"], var.media_mode)
    error_message = "media_mode must be s3 or efs."
  }
}
data "aws_caller_identity" "current" {}
data "aws_availability_zones" "available" {
  state = "available"
}

locals {
  # One workspace per environment; "default" is deliberately not a key.
  environments = {
    staging = {
      env                  = "staging"
      site_host            = "staging.pocketcoffee.example"
      media_host           = "media-staging.pocketcoffee.example"
      vpc_cidr             = "10.30.0.0/16"
      single_nat           = true
      kubernetes_version   = "1.36"
      db_instance_class    = "db.t4g.large"
      db_multi_az          = false
      db_allocated_gb      = 100
      db_deletion_protect  = false
      search_instance_type = "m7g.large.search"
      search_nodes         = 3
      search_dedicated     = false
      cache_node_type      = "cache.t4g.medium"
      cache_replicas       = 1
      session_node_type    = "cache.t4g.small"
      session_replicas     = 1
      mq_instance_type     = "mq.m7g.large"
      mq_mode              = "SINGLE_INSTANCE"
    }
    production = {
      env                  = "prod"
      site_host            = "www.pocketcoffee.example"
      media_host           = "media.pocketcoffee.example"
      vpc_cidr             = "10.10.0.0/16"
      single_nat           = false
      kubernetes_version   = "1.36"
      db_instance_class    = "db.r7g.xlarge"
      db_multi_az          = true
      db_allocated_gb      = 300
      db_deletion_protect  = true
      search_instance_type = "r7g.large.search"
      search_nodes         = 3
      search_dedicated     = true
      cache_node_type      = "cache.r7g.large"
      cache_replicas       = 2
      session_node_type    = "cache.r7g.large"
      session_replicas     = 2
      mq_instance_type     = "mq.m7g.large"
      mq_mode              = "CLUSTER_MULTI_AZ"
    }
  }

  cfg  = local.environments[terraform.workspace]
  env  = local.cfg.env
  name = "pocketcoffee-${local.env}"
  azs  = slice(data.aws_availability_zones.available.names, 0, 3)

  # Secrets Manager names follow pocketcoffee/<env>/<purpose>.
  secret_prefix = "pocketcoffee/${local.env}"

  # The shop is always created with this namespace and service account.
  namespace       = "pocketcoffee"
  service_account = "pocketcoffee-magento"
}

The providers are AWS (twice, the second with the alias us_east_1, because a certificate for CloudFront must be requested there) and random. admin_cidrs is used for the Kubernetes API endpoint and for both Admin rules of the web ACL, and holds a documentation address range here; replace it with the office and VPN ranges. admin_front_name repeats the value of the chart’s shop.adminFrontName and the environment variable MAGENTO_BACKEND_FRONTNAME, and the three must match. The staging workspace is a smaller independent copy: its own cluster, database and data tier, one NAT gateway, no Multi-AZ, no dedicated OpenSearch master nodes and a single-instance broker. media_mode and github_oidc_provider_arn are the two switches that change which resources exist.

Network and encryption key

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "6.7.3"

  name = local.name
  cidr = local.cfg.vpc_cidr
  azs  = local.azs

  public_subnets      = [for i in range(3) : cidrsubnet(local.cfg.vpc_cidr, 8, i)]
  private_subnets     = [for i in range(3) : cidrsubnet(local.cfg.vpc_cidr, 4, i + 1)]
  database_subnets    = [for i in range(3) : cidrsubnet(local.cfg.vpc_cidr, 8, i + 100)]
  elasticache_subnets = [for i in range(3) : cidrsubnet(local.cfg.vpc_cidr, 8, i + 110)]

  create_database_subnet_group    = true
  create_elasticache_subnet_group = true

  enable_nat_gateway     = true
  single_nat_gateway     = local.cfg.single_nat
  one_nat_gateway_per_az = !local.cfg.single_nat

  public_subnet_tags  = { "kubernetes.io/role/elb" = 1 }
  private_subnet_tags = { "kubernetes.io/role/internal-elb" = 1 }
}

resource "aws_vpc_endpoint" "s3" {
  vpc_id            = module.vpc.vpc_id
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = module.vpc.private_route_table_ids
}
module "kms" {
  source  = "terraform-aws-modules/kms/aws"
  version = "4.2.2"

  description = "${local.name}: Secrets Manager, RDS, OpenSearch, ElastiCache, Amazon MQ and EFS"
  aliases     = ["pocketcoffee/${local.env}"]

  enable_key_rotation = true
}

Identical in structure to the other platforms: three /20 private subnets (pods take their addresses from them), three public /24 subnets for the load balancer, small database and cache subnets with their subnet groups, an S3 gateway endpoint so that image layers and media transfers skip the NAT gateways, and one key with rotation for the data tier and the secrets. The subnet tags are how the load balancer controller finds where to place the load balancer.

The cluster and its identities

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "21.29.0"

  name               = local.name
  kubernetes_version = local.cfg.kubernetes_version

  vpc_id                   = module.vpc.vpc_id
  subnet_ids               = module.vpc.private_subnets
  control_plane_subnet_ids = module.vpc.private_subnets

  endpoint_public_access       = true
  endpoint_public_access_cidrs = var.admin_cidrs
  endpoint_private_access      = true

  enable_cluster_creator_admin_permissions = true

  # The deployment role of the GitHub workflow may administer the shop's namespace and nothing else.
  access_entries = {
    github_deploy = {
      principal_arn = aws_iam_role.github_deploy.arn
      policy_associations = {
        namespace_admin = {
          policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy"
          access_scope = {
            type       = "namespace"
            namespaces = [local.namespace]
          }
        }
      }
    }
  }

  compute_config = {
    enabled    = true
    node_pools = ["general-purpose"]
  }

  addons = {
    eks-pod-identity-agent = {
      before_compute = true
    }
    aws-efs-csi-driver = {
      pod_identity_association = [{
        role_arn        = aws_iam_role.efs_csi.arn
        service_account = "efs-csi-controller-sa"
      }]
    }
  }
}

data "aws_iam_policy_document" "pods_trust" {
  statement {
    actions = ["sts:AssumeRole", "sts:TagSession"]
    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "efs_csi" {
  name               = "${local.name}-efs-csi"
  assume_role_policy = data.aws_iam_policy_document.pods_trust.json
}

resource "aws_iam_role_policy_attachment" "efs_csi" {
  role       = aws_iam_role.efs_csi.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy"
}

# External Secrets Operator: read only the secrets of this environment.
data "aws_iam_policy_document" "external_secrets" {
  statement {
    actions   = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
    resources = ["arn:aws:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:${local.secret_prefix}/*"]
  }
  statement {
    actions   = ["kms:Decrypt"]
    resources = [module.kms.key_arn]
  }
}

resource "aws_iam_role" "external_secrets" {
  name               = "${local.name}-external-secrets"
  assume_role_policy = data.aws_iam_policy_document.pods_trust.json
}

resource "aws_iam_role_policy" "external_secrets" {
  role   = aws_iam_role.external_secrets.id
  policy = data.aws_iam_policy_document.external_secrets.json
}

resource "aws_eks_pod_identity_association" "external_secrets" {
  cluster_name    = module.eks.cluster_name
  namespace       = "external-secrets"
  service_account = "external-secrets"
  role_arn        = aws_iam_role.external_secrets.arn
}

EKS Auto Mode runs node provisioning and patching, the block storage driver and the pod networking; the EFS CSI driver is an add-on with its own Pod Identity role (installed in both modes, because the driver costs nothing and the switch to EFS then needs no cluster change), and the External Secrets Operator reads only pocketcoffee/<env>/*. The WordPress page explains each of these points, including why enable_cluster_creator_admin_permissions should be replaced by access entries for people. One addition for Magento: access_entries gives the GitHub deployment role (ecr.tf) the cluster access policy AmazonEKSAdminPolicy scoped to the namespace pocketcoffee, so the pipeline can run helm upgrade without any cluster-wide right. The role of the shop’s own pods is in media.tf. Auto Mode does not include a Gateway API controller, so the platform team installs the AWS Load Balancer Controller for the Gateway that the chart’s HTTPRoute objects attach to (see Deploying Microservices on EKS). Node replacement under Auto Mode happens at least every 21 days, so the chart’s PodDisruptionBudgets must allow a drain.

Database

resource "aws_security_group" "db" {
  name_prefix = "${local.name}-db-"
  description = "MySQL from the cluster nodes"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "db_from_cluster" {
  security_group_id            = aws_security_group.db.id
  referenced_security_group_id = module.eks.cluster_primary_security_group_id
  from_port                    = 3306
  to_port                      = 3306
  ip_protocol                  = "tcp"
  description                  = "MySQL from pods on EKS nodes"
}

module "db" {
  source  = "terraform-aws-modules/rds/aws"
  version = "7.2.2"

  identifier = local.name

  engine               = "mysql"
  engine_version       = "8.4"
  family               = "mysql8.4"
  major_engine_version = "8.4"
  instance_class       = local.cfg.db_instance_class

  allocated_storage     = local.cfg.db_allocated_gb
  max_allocated_storage = local.cfg.db_allocated_gb * 4
  storage_type          = "gp3"
  storage_encrypted     = true
  kms_key_id            = module.kms.key_arn

  db_name  = "pocketcoffee"
  username = "mg_admin"
  port     = 3306

  # RDS creates and rotates the master password in Secrets Manager. The users
  # mg_app and mg_migrate are created by the bootstrap step (see the text).
  manage_master_user_password   = true
  master_user_secret_kms_key_id = module.kms.key_arn

  multi_az               = local.cfg.db_multi_az
  db_subnet_group_name   = module.vpc.database_subnet_group_name
  vpc_security_group_ids = [aws_security_group.db.id]
  publicly_accessible    = false

  backup_retention_period = 14
  backup_window           = "02:00-03:00"
  maintenance_window      = "Mon:03:30-Mon:04:30"
  copy_tags_to_snapshot   = true
  deletion_protection     = local.cfg.db_deletion_protect
  skip_final_snapshot     = !local.cfg.db_deletion_protect

  auto_minor_version_upgrade      = true
  enabled_cloudwatch_logs_exports = ["error", "slowquery"]

  parameter_group_name            = "pocketcoffee-magento-mysql84"
  parameter_group_use_name_prefix = false
  parameter_group_description     = "Magento 2.4.9 on MySQL 8.4"

  parameters = [
    { name = "character_set_server", value = "utf8mb4" },
    { name = "collation_server", value = "utf8mb4_general_ci" },
    # Magento creates triggers (mview) and, with automated backups, binary logging is on.
    { name = "log_bin_trust_function_creators", value = "1" },
    { name = "max_allowed_packet", value = "134217728" },
    { name = "slow_query_log", value = "1" },
    { name = "long_query_time", value = "1" },
    { name = "max_connections", value = "800", apply_method = "pending-reboot" },
  ]
}

manage_master_user_password lets RDS create and rotate the master password in Secrets Manager, so it never passes through OpenTofu. The application connects as mg_app and the Jobs as mg_migrate, two users created after the first apply with the grants described on Cloud: Managed Database (RDS and Aurora), which is also where the connection arithmetic (pods x pm.max_children against max_connections), TLS and RDS Proxy are covered. The passwords of both are generated by the seeding script below and written into the secret; the SQL that creates the users reads them from there. Parameter notes:

  • utf8mb4 and utf8mb4_general_ci are the character set and collation Magento uses; in 2.4.9 the adapter chooses its own SET NAMES statement when env.php has no initStatements (DB/Adapter/Pdo/Mysql.php has a default derived from the database version), which is why the env.php below omits the key. The installer’s old SET NAMES utf8 is removed by setup:upgrade from any file that still has it, and that removal is the one write to env.php the upgrade can make.

  • log_bin_trust_function_creators = 1: RDS keeps binary logs whenever automated backups are on, and then MariaDB refuses to create a trigger or a function unless the user has the superuser privilege or this variable is on (MySQL 8.4 created the trigger without it in the test on the database page). Magento creates triggers for its materialized views (the "Update by Schedule" indexers), so on MariaDB the schema upgrade fails without it; the group sets it on either engine. The reasoning and the alternatives are on the database page.

  • max_allowed_packet is raised to 128 MiB for large imports and for the serialized configuration that some modules store.

  • There is no sql_mode entry on purpose: the page on the database says what Magento sets per connection; change nothing here without testing setup:upgrade.

OpenSearch

resource "aws_security_group" "search" {
  name_prefix = "${local.name}-search-"
  description = "OpenSearch HTTPS from the cluster nodes"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "search_from_cluster" {
  security_group_id            = aws_security_group.search.id
  referenced_security_group_id = module.eks.cluster_primary_security_group_id
  from_port                    = 443
  to_port                      = 443
  ip_protocol                  = "tcp"
  description                  = "OpenSearch from pods on EKS nodes"
}

# The password is embedded in a URL by Magento, so it uses only URL-safe characters.
resource "random_password" "search" {
  length           = 32
  special          = true
  override_special = "-_."
  min_upper        = 2
  min_lower        = 2
  min_numeric      = 2
  min_special      = 1
}

resource "aws_opensearch_domain" "main" {
  domain_name    = local.name
  engine_version = "OpenSearch_3.5"

  cluster_config {
    instance_type            = local.cfg.search_instance_type
    instance_count           = local.cfg.search_nodes
    zone_awareness_enabled   = true
    dedicated_master_enabled = local.cfg.search_dedicated
    dedicated_master_type    = local.cfg.search_dedicated ? "m7g.large.search" : null
    dedicated_master_count   = local.cfg.search_dedicated ? 3 : null

    zone_awareness_config {
      availability_zone_count = 3
    }
  }

  ebs_options {
    ebs_enabled = true
    volume_type = "gp3"
    volume_size = 200
  }

  vpc_options {
    subnet_ids         = module.vpc.private_subnets
    security_group_ids = [aws_security_group.search.id]
  }

  encrypt_at_rest {
    enabled    = true
    kms_key_id = module.kms.key_arn
  }

  node_to_node_encryption {
    enabled = true
  }

  domain_endpoint_options {
    enforce_https       = true
    tls_security_policy = "Policy-Min-TLS-1-2-2019-07"
  }

  advanced_security_options {
    enabled                        = true
    internal_user_database_enabled = true
    master_user_options {
      master_user_name     = "magento"
      master_user_password = random_password.search.result
    }
  }

  # Fine-grained access control does the authorisation; the resource policy only opens the domain to the VPC.
  access_policies = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = "*" }
      Action    = "es:ESHttp*"
      Resource  = "arn:aws:es:${var.region}:${data.aws_caller_identity.current.account_id}:domain/${local.name}/*"
    }]
  })

  auto_tune_options {
    desired_state = "ENABLED"
  }
}

The domain is private (VPC access only), reachable from the cluster on port 443, encrypted at rest and between nodes, and requires HTTPS. Authorisation is fine-grained access control with the internal user database and one master user, magento, because Magento’s OpenSearch client authenticates with a user name and password and cannot sign requests with IAM. Points that matter:

  • The password is embedded in a URL. Magento builds https://user:password@host:port from its search settings (the client class of the OpenSearch module does exactly that), so a password with @, : or / breaks the connection. The generated password therefore uses only letters, digits and -_., and still satisfies the domain’s rule of upper case, lower case, a digit and a special character.

  • Three zones. zone_awareness_enabled with three zones needs a multiple of three data nodes; production adds three dedicated master nodes. Sizes are placeholders for the catalogue size of the shop, and OpenSearch sizing is not Magento-specific; the managed-services page lists the checks.

  • Version. OpenSearch_3.5 is the current version of the service and Adobe’s 2.4.9 requirement is "OpenSearch 3"; Adobe’s AWS row says 3.1 or latest available. The resource policy opens the domain to the VPC only because the security group and fine-grained access control do the real work.

Sessions and the cache

resource "aws_security_group" "cache" {
  name_prefix = "${local.name}-cache-"
  description = "Valkey from the cluster nodes"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "valkey_from_cluster" {
  security_group_id            = aws_security_group.cache.id
  referenced_security_group_id = module.eks.cluster_primary_security_group_id
  from_port                    = 6379
  to_port                      = 6379
  ip_protocol                  = "tcp"
  description                  = "Valkey (TLS) from pods on EKS nodes"
}

# AUTH tokens: 16 to 128 printable characters, no @, " or /. They land in the (encrypted) state.
resource "random_password" "valkey" {
  for_each = toset(["cache", "session"])

  length  = 48
  special = false
}

# The Magento cache: rebuildable, so no snapshots; entries expire, tag sets do not, hence volatile-lru.
resource "aws_elasticache_parameter_group" "cache" {
  name        = "${local.name}-cache-valkey9"
  family      = "valkey9"
  description = "Magento cache"

  parameter {
    name  = "maxmemory-policy"
    value = "volatile-lru"
  }
}

# Sessions: never evicted; a full instance refuses writes instead of logging customers out.
resource "aws_elasticache_parameter_group" "session" {
  name        = "${local.name}-session-valkey9"
  family      = "valkey9"
  description = "Magento sessions"

  parameter {
    name  = "maxmemory-policy"
    value = "noeviction"
  }
}

resource "aws_elasticache_replication_group" "cache" {
  replication_group_id = "${local.name}-cache"
  description          = "Magento cache for ${local.name}"

  engine         = "valkey"
  engine_version = "9.0"
  node_type      = local.cfg.cache_node_type
  port           = 6379

  num_cache_clusters         = local.cfg.cache_replicas + 1
  automatic_failover_enabled = true
  multi_az_enabled           = true

  subnet_group_name    = module.vpc.elasticache_subnet_group_name
  security_group_ids   = [aws_security_group.cache.id]
  parameter_group_name = aws_elasticache_parameter_group.cache.name

  at_rest_encryption_enabled = true
  kms_key_id                 = module.kms.key_arn
  transit_encryption_enabled = true
  auth_token                 = random_password.valkey["cache"].result

  snapshot_retention_limit = 0
}

resource "aws_elasticache_replication_group" "session" {
  replication_group_id = "${local.name}-session"
  description          = "Magento sessions for ${local.name}"

  engine         = "valkey"
  engine_version = "9.0"
  node_type      = local.cfg.session_node_type
  port           = 6379

  num_cache_clusters         = local.cfg.session_replicas + 1
  automatic_failover_enabled = true
  multi_az_enabled           = true

  subnet_group_name    = module.vpc.elasticache_subnet_group_name
  security_group_ids   = [aws_security_group.cache.id]
  parameter_group_name = aws_elasticache_parameter_group.session.name

  at_rest_encryption_enabled = true
  kms_key_id                 = module.kms.key_arn
  transit_encryption_enabled = true
  auth_token                 = random_password.valkey["session"].result

  snapshot_retention_limit = 1
  snapshot_window          = "01:00-02:00"
}

Two replication groups with cluster mode disabled, TLS in transit, an AUTH token and Multi-AZ failover: pocketcoffee-prod-cache for the Magento cache and pocketcoffee-prod-session for sessions. The groups differ in what they promise. Sessions are not rebuildable (an evicted session logs a customer out and empties a cart in progress), so that group uses noeviction, has one snapshot a day and fails writes loudly when full. The cache is rebuildable but not evictable at will: the cache backend of Magento stores each entry with an expiry but keeps its tag index (the sets that make "clean by tag" work) without one, so a policy that evicts any key could delete part of an index and leave entries that can no longer be invalidated; volatile-lru evicts only keys that carry an expiry. Adobe’s pages on Valkey (read for this page) do not name a policy, so treat volatile-lru as a reasoned choice, watch the memory alarms of the operations page, and size the group so that it does not fill. The auth tokens are 48 letters and digits (ElastiCache accepts 16 to 128 printable characters and forbids a few, among them @, " and /) and exist in the encrypted state, as on the WordPress page; outputs.tf exposes them as sensitive outputs for the seeding script. Valkey 9.0 is an engine version that ElastiCache lists; Adobe’s 2.4.9 table for AWS names Valkey 8 as available, so engine_version and the parameter group family (valkey9) are the two lines to change if you stay on 8.

Message broker

resource "aws_security_group" "mq" {
  name_prefix = "${local.name}-mq-"
  description = "AMQP over TLS and the management API from the cluster nodes"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "mq_amqps_from_cluster" {
  security_group_id            = aws_security_group.mq.id
  referenced_security_group_id = module.eks.cluster_primary_security_group_id
  from_port                    = 5671
  to_port                      = 5671
  ip_protocol                  = "tcp"
  description                  = "AMQP over TLS from pods on EKS nodes"
}

resource "aws_vpc_security_group_ingress_rule" "mq_management_from_cluster" {
  security_group_id            = aws_security_group.mq.id
  referenced_security_group_id = module.eks.cluster_primary_security_group_id
  from_port                    = 443
  to_port                      = 443
  ip_protocol                  = "tcp"
  description                  = "Management API (KEDA queue length) from pods on EKS nodes"
}

# Letters and digits only: KEDA puts the password into a URL, and Amazon MQ forbids a few characters anyway.
resource "random_password" "mq" {
  length  = 32
  special = false
}

resource "aws_mq_broker" "main" {
  broker_name = "${local.name}-mq"

  engine_type        = "RabbitMQ"
  engine_version     = "4.3"
  host_instance_type = local.cfg.mq_instance_type
  deployment_mode    = local.cfg.mq_mode

  # Single instance: one subnet. Cluster: at least one; here one per zone.
  subnet_ids          = local.cfg.mq_mode == "SINGLE_INSTANCE" ? [module.vpc.private_subnets[0]] : module.vpc.private_subnets
  security_groups     = [aws_security_group.mq.id]
  publicly_accessible = false

  auto_minor_version_upgrade = true

  encryption_options {
    kms_key_id        = module.kms.key_arn
    use_aws_owned_key = false
  }

  logs {
    general = true
  }

  maintenance_window_start_time {
    day_of_week = "SUNDAY"
    time_of_day = "03:00"
    time_zone   = "UTC"
  }

  user {
    username = "magento"
    password = random_password.mq.result
  }
}

Amazon MQ for RabbitMQ 4.3 on mq.m7g.large, a three-node cluster in production (CLUSTER_MULTI_AZ) and a single instance in staging, private, encrypted with the customer-managed key and with general logs on. The API reference says that a private cluster needs at least one subnet and that several subnets must be in different zones; the listing passes one per zone. Port 5671 is AMQP over TLS, the port of the chart’s amqp.port; port 443 is the broker’s management API, which KEDA reads to count messages in a queue. The password is letters and digits only because KEDA puts it into a URL, and its documentation says the password must not contain special characters. The user is the single user magento, which is the administrator of the broker; a separate monitoring-only user for KEDA is the stricter design, and creating one needs the broker’s management API (check Amazon’s RabbitMQ pages for the user tags your engine version supports). Amazon MQ does not support RabbitMQ streams, and Magento does not use them.

Media bucket and the pods' role

# The bucket behind remote storage (driver aws-s3) and the role of the Magento pods.
# The media page extends this file with the bucket policy, CloudFront and the DNS record.
resource "aws_s3_bucket" "media" {
  bucket = "pocketcoffee-${local.env}-media"
}

resource "aws_s3_bucket_public_access_block" "media" {
  bucket                  = aws_s3_bucket.media.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

data "aws_iam_policy_document" "magento_media" {
  statement {
    sid       = "ListBucket"
    actions   = ["s3:ListBucket", "s3:GetBucketLocation"]
    resources = [aws_s3_bucket.media.arn]
  }
  statement {
    sid = "ReadWriteObjects"
    actions = [
      "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
      "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts",
    ]
    resources = ["${aws_s3_bucket.media.arn}/*"]
  }
}

resource "aws_iam_role" "magento" {
  name               = "${local.name}-magento"
  assume_role_policy = data.aws_iam_policy_document.pods_trust.json
}

resource "aws_iam_role_policy" "magento_media" {
  name   = "media-bucket"
  role   = aws_iam_role.magento.id
  policy = data.aws_iam_policy_document.magento_media.json
}

# Every workload of the chart uses this service account.
resource "aws_eks_pod_identity_association" "magento" {
  cluster_name    = module.eks.cluster_name
  namespace       = local.namespace
  service_account = local.service_account
  role_arn        = aws_iam_role.magento.arn
}
# Only for media_mode = "efs": pub/media on a shared file system instead of S3.
resource "aws_security_group" "efs" {
  count = var.media_mode == "efs" ? 1 : 0

  name_prefix = "${local.name}-efs-"
  description = "NFS from the cluster nodes"
  vpc_id      = module.vpc.vpc_id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "efs_from_cluster" {
  count = var.media_mode == "efs" ? 1 : 0

  security_group_id            = aws_security_group.efs[0].id
  referenced_security_group_id = module.eks.cluster_primary_security_group_id
  from_port                    = 2049
  to_port                      = 2049
  ip_protocol                  = "tcp"
  description                  = "NFS from pods on EKS nodes"
}

module "efs" {
  source  = "terraform-aws-modules/efs/aws"
  version = "2.2.1"

  create = var.media_mode == "efs"

  name        = "pocketcoffee-${local.env}-media"
  encrypted   = true
  kms_key_arn = module.kms.key_arn

  performance_mode = "generalPurpose"
  throughput_mode  = "elastic"

  mount_targets = {
    for i, subnet_id in module.vpc.private_subnets : "az${i}" => {
      subnet_id       = subnet_id
      security_groups = [one(aws_security_group.efs[*].id)]
    }
  }

  # pub/media lives below /media; every client is UID/GID 33 (www-data).
  access_points = {
    media = {
      name = "media"
      posix_user = {
        uid = 33
        gid = 33
      }
      root_directory = {
        path = "/media"
        creation_info = {
          owner_uid   = 33
          owner_gid   = 33
          permissions = "0775"
        }
      }
    }
  }

  enable_backup_policy = true
}

media.tf is the minimum that the platform needs: the private bucket pocketcoffee-prod-media, the role pocketcoffee-prod-magento that allows read, write, delete and list on it, and the Pod Identity association that binds the role to the service account pocketcoffee-magento of the namespace pocketcoffee, the account every workload of the chart uses. The bucket policy for CloudFront (an origin access control), the distribution for media.pocketcoffee.example, the lifecycle rules and the Route 53 record extend this file on Cloud: Media and Remote Storage; the resource names aws_s3_bucket.media and aws_iam_role.magento are the same there. Magento’s S3 driver needs no access key when none is configured: AwsS3Factory falls back to the default credential provider chain of the AWS SDK for PHP and caches the result in Magento’s cache. Whether the SDK version in your composer.lock reads Pod Identity credentials (the variables AWS_CONTAINER_CREDENTIALS_FULL_URI and AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE) is a property of that version: check the SDK’s changelog, and test a pod before relying on it.

efs.tf is created only with media_mode = "efs": one encrypted file system, a mount target per zone and one access point, /media, owned by UID and GID 33, so the pods need no root and no ownership changes. The chart mounts it at pub/media.

Registry, deployment role, secrets, certificates and web ACL

variable "github_oidc_provider_arn" {
  description = "ARN of the account's GitHub OIDC provider; empty creates it (one per account, so only one workspace does)."
  type        = string
  default     = ""
}

module "ecr" {
  source  = "terraform-aws-modules/ecr/aws"
  version = "3.2.1"

  for_each = toset(["magento", "varnish"])

  # Created in the production workspace only; staging pulls from the same repositories.
  create = terraform.workspace == "production"

  repository_name                 = "pocketcoffee/${each.key}"
  repository_image_tag_mutability = "IMMUTABLE"
  repository_image_scan_on_push   = true

  repository_lifecycle_policy = jsonencode({
    rules = [{
      rulePriority = 1
      description  = "Keep the last 60 tags"
      selection = {
        tagStatus   = "any"
        countType   = "imageCountMoreThan"
        countNumber = 60
      }
      action = { type = "expire" }
    }]
  })
}

resource "aws_iam_openid_connect_provider" "github" {
  count = var.github_oidc_provider_arn == "" ? 1 : 0

  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}

locals {
  github_oidc_arn = var.github_oidc_provider_arn != "" ? var.github_oidc_provider_arn : aws_iam_openid_connect_provider.github[0].arn
}

data "aws_iam_policy_document" "github_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [local.github_oidc_arn]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }
    # The workflow job must run in the GitHub environment named after the workspace.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${var.github_repository}:environment:${terraform.workspace}"]
    }
  }
}

resource "aws_iam_role" "github_deploy" {
  name               = "${local.name}-github-deploy"
  assume_role_policy = data.aws_iam_policy_document.github_trust.json
}

data "aws_iam_policy_document" "github_deploy" {
  statement {
    actions   = ["ecr:GetAuthorizationToken"]
    resources = ["*"]
  }
  statement {
    actions = [
      "ecr:BatchCheckLayerAvailability", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart",
      "ecr:CompleteLayerUpload", "ecr:PutImage", "ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer",
    ]
    resources = ["arn:aws:ecr:${var.region}:${data.aws_caller_identity.current.account_id}:repository/pocketcoffee/*"]
  }
  statement {
    actions   = ["eks:DescribeCluster"]
    resources = [module.eks.cluster_arn]
  }
}

resource "aws_iam_role_policy" "github_deploy" {
  role   = aws_iam_role.github_deploy.id
  policy = data.aws_iam_policy_document.github_deploy.json
}
# Containers only: scripts/seed-secrets.sh writes the values, so the crypt key and the passwords
# never appear in the configuration, and the crypt key never appears in the state.
resource "aws_secretsmanager_secret" "magento" {
  name        = "${local.secret_prefix}/magento"
  description = "db_app_password, db_migrate_password, crypt_key, opensearch_password, valkey_cache_auth_token, valkey_session_auth_token, amqp_password"
  kms_key_id  = module.kms.key_arn

  recovery_window_in_days = local.env == "prod" ? 30 : 0
}

# The first administrator's password, read once by the install Job; delete it afterwards.
resource "aws_secretsmanager_secret" "install" {
  name        = "${local.secret_prefix}/magento-install"
  description = "ADMIN_PASSWORD for the first install"
  kms_key_id  = module.kms.key_arn

  recovery_window_in_days = local.env == "prod" ? 30 : 0
}
data "aws_route53_zone" "root" {
  name         = var.root_domain
  private_zone = false
}

# Regional certificate for the load balancer. The apex redirects to www in production.
resource "aws_acm_certificate" "site" {
  domain_name               = local.cfg.site_host
  validation_method         = "DNS"
  subject_alternative_names = local.env == "prod" ? [var.root_domain] : []

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "site_validation" {
  for_each = {
    for o in aws_acm_certificate.site.domain_validation_options : o.domain_name => {
      name   = o.resource_record_name
      type   = o.resource_record_type
      record = o.resource_record_value
    }
  }

  zone_id         = data.aws_route53_zone.root.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
  allow_overwrite = true
}

resource "aws_acm_certificate_validation" "site" {
  certificate_arn         = aws_acm_certificate.site.arn
  validation_record_fqdns = [for r in aws_route53_record.site_validation : r.fqdn]
}

# Certificate of the media host for CloudFront: it must live in us-east-1.
resource "aws_acm_certificate" "media" {
  provider = aws.us_east_1

  domain_name       = local.cfg.media_host
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "media_validation" {
  for_each = {
    for o in aws_acm_certificate.media.domain_validation_options : o.domain_name => {
      name   = o.resource_record_name
      type   = o.resource_record_type
      record = o.resource_record_value
    }
  }

  zone_id         = data.aws_route53_zone.root.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
  allow_overwrite = true
}

resource "aws_acm_certificate_validation" "media" {
  provider = aws.us_east_1

  certificate_arn         = aws_acm_certificate.media.arn
  validation_record_fqdns = [for r in aws_route53_record.media_validation : r.fqdn]
}
locals {
  waf_rules = [
    # SizeRestrictions_BODY blocks any request body over 8 KB, which every Admin product form exceeds.
    { name = "AWSManagedRulesCommonRuleSet", priority = 10, count_overrides = ["SizeRestrictions_BODY"] },
    { name = "AWSManagedRulesKnownBadInputsRuleSet", priority = 20, count_overrides = [] },
    { name = "AWSManagedRulesSQLiRuleSet", priority = 30, count_overrides = [] },
    { name = "AWSManagedRulesPHPRuleSet", priority = 40, count_overrides = [] },
  ]
}

resource "aws_wafv2_ip_set" "admin" {
  name               = "${local.name}-admin"
  scope              = "REGIONAL"
  ip_address_version = "IPV4"
  addresses          = var.admin_cidrs
}

# Regional web ACL, attached to the load balancer.
resource "aws_wafv2_web_acl" "alb" {
  name  = local.name
  scope = "REGIONAL"

  default_action {
    allow {}
  }

  # The Admin answers only to the office and VPN addresses.
  rule {
    name     = "admin-allow-list"
    priority = 1

    action {
      block {}
    }

    statement {
      and_statement {
        statement {
          byte_match_statement {
            search_string         = "/${var.admin_front_name}"
            positional_constraint = "STARTS_WITH"
            field_to_match {
              uri_path {}
            }
            text_transformation {
              priority = 0
              type     = "LOWERCASE"
            }
          }
        }
        statement {
          not_statement {
            statement {
              ip_set_reference_statement {
                arn = aws_wafv2_ip_set.admin.arn
              }
            }
          }
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "admin-allow-list"
      sampled_requests_enabled   = true
    }
  }

  # The REST endpoint that trades an administrator's password for a token bypasses the Admin URL.
  rule {
    name     = "admin-token-allow-list"
    priority = 2

    action {
      block {}
    }

    statement {
      and_statement {
        statement {
          byte_match_statement {
            search_string         = "/v1/integration/admin/token"
            positional_constraint = "ENDS_WITH"
            field_to_match {
              uri_path {}
            }
            text_transformation {
              priority = 0
              type     = "LOWERCASE"
            }
          }
        }
        statement {
          not_statement {
            statement {
              ip_set_reference_statement {
                arn = aws_wafv2_ip_set.admin.arn
              }
            }
          }
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "admin-token-allow-list"
      sampled_requests_enabled   = true
    }
  }

  rule {
    name     = "login-rate-limit"
    priority = 3

    action {
      block {}
    }

    statement {
      rate_based_statement {
        limit              = 100
        aggregate_key_type = "IP"
        scope_down_statement {
          byte_match_statement {
            search_string         = "/customer/account/login"
            positional_constraint = "STARTS_WITH"
            field_to_match {
              uri_path {}
            }
            text_transformation {
              priority = 0
              type     = "LOWERCASE"
            }
          }
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "login-rate-limit"
      sampled_requests_enabled   = true
    }
  }

  dynamic "rule" {
    for_each = local.waf_rules
    content {
      name     = rule.value.name
      priority = rule.value.priority

      override_action {
        none {}
      }

      statement {
        managed_rule_group_statement {
          name        = rule.value.name
          vendor_name = "AWS"

          dynamic "rule_action_override" {
            for_each = rule.value.count_overrides
            content {
              name = rule_action_override.value
              action_to_use {
                count {}
              }
            }
          }
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = rule.value.name
        sampled_requests_enabled   = true
      }
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = local.name
    sampled_requests_enabled   = true
  }
}
  • ECR. Two repositories, pocketcoffee/magento (two tags per release, see below) and pocketcoffee/varnish (a copy of the official Varnish image, so the cluster never pulls from a public registry at scale-out). Tags are immutable and scan on push is on, so a <git-sha> is never overwritten and "promote the image" means something; the repositories exist only in the production workspace and staging pulls from them.

  • GitHub deployment role. pocketcoffee-prod-github-deploy pushes images and may call eks:DescribeCluster; with the access entry of eks.tf it can also run Helm in the namespace. It trusts only workflow jobs of one repository that run in the GitHub environment named like the workspace (environment:production), so a branch alone is not enough; the workflow is on Cloud: Releases and Zero-Downtime Deployments. An account has one GitHub OIDC provider: the first workspace creates it, the second passes its ARN in github_oidc_provider_arn.

  • Secrets. Two containers: pocketcoffee/prod/magento with the seven keys of the table below, and pocketcoffee/prod/magento-install with the first administrator’s password, to delete after the install. Neither has a value in the configuration.

  • Certificates. A regional certificate for the load balancer, with the apex name as a second name in production (the apex only redirects to www), and the certificate of the media host in us-east-1, which CloudFront requires. The hosted zone is a data source: the zone and its delegation are not part of this configuration.

  • Web ACL. Rule 1 blocks the path of admin_front_name for addresses outside the IP set; rule 2 does the same for the REST route /V1/integration/admin/token, which exchanges an administrator’s credentials for a token and is reachable from outside the Admin path; rule 3 rate-limits the customer login pages; then four AWS managed rule groups. Managed groups produce false positives on a shop, so start them in count mode (override_action { count {} }), review the sampled requests and enforce afterwards. The Common rule set contains SizeRestrictions_BODY, which blocks request bodies over 8 KB and therefore every Admin product form; the listing overrides that one rule to count. The web ACL is attached to the load balancer by the Gateway’s configuration, not by a Kubernetes annotation on the route.

Outputs

output "cluster_name" {
  value = module.eks.cluster_name
}

output "db_endpoint" {
  description = "Goes into the Helm value database.host."
  value       = module.db.db_instance_address
}

output "db_master_secret_arn" {
  value = module.db.db_instance_master_user_secret_arn
}

output "opensearch_endpoint" {
  description = "Goes into the OpenSearch host of the environment (MAGENTO_OPENSEARCH_HOST)."
  value       = aws_opensearch_domain.main.endpoint
}

output "valkey_cache_endpoint" {
  value = aws_elasticache_replication_group.cache.primary_endpoint_address
}

output "valkey_session_endpoint" {
  value = aws_elasticache_replication_group.session.primary_endpoint_address
}

output "amqp_host" {
  description = "Host name of the RabbitMQ endpoint (amqps, port 5671)."
  value       = regex("^amqps://([^:/]+)", aws_mq_broker.main.instances[0].endpoints[0])[0]
}

output "media_bucket" {
  value = aws_s3_bucket.media.bucket
}

output "media_certificate_arn" {
  description = "Certificate for the media host, in us-east-1, for CloudFront."
  value       = aws_acm_certificate_validation.media.certificate_arn
}

output "efs_file_system_id" {
  description = "Goes into the Helm value media.efs.fileSystemId (media_mode = efs)."
  value       = module.efs.id
}

output "efs_access_point_id" {
  description = "Goes into the Helm value media.efs.accessPointId (media_mode = efs)."
  value       = try(module.efs.access_points["media"].id, "")
}

output "site_certificate_arn" {
  value = aws_acm_certificate_validation.site.certificate_arn
}

output "waf_acl_arn" {
  value = aws_wafv2_web_acl.alb.arn
}

output "ecr_repository_urls" {
  value = { for k, m in module.ecr : k => m.repository_url }
}

output "github_deploy_role_arn" {
  value = aws_iam_role.github_deploy.arn
}

# Sensitive: read by scripts/seed-secrets.sh, never printed by it.
output "opensearch_master_password" {
  value     = random_password.search.result
  sensitive = true
}

output "valkey_auth_tokens" {
  value     = { for k, p in random_password.valkey : k => p.result }
  sensitive = true
}

output "amqp_password" {
  value     = random_password.mq.result
  sensitive = true
}

The non-sensitive outputs are the values the chart consumes (db_endpoint, opensearch_endpoint, valkey_cache_endpoint, valkey_session_endpoint, amqp_host, media_bucket, the EFS identifiers, the two ARNs of the load balancer configuration). The three sensitive ones, opensearch_master_password, valkey_auth_tokens and amqp_password, exist for the seeding script and are masked in the plan output; they come from the state, so read access to the state is read access to them.

Validating and running it

cd infra
tofu init -backend-config="bucket=pocketcoffee-tfstate-123456789012"
tofu workspace select -or-create staging
tofu plan -out=staging.plan
tofu apply staging.plan

# Offline check, without credentials or a backend (one workspace at a time):
tofu init -backend=false
tofu fmt -check
TF_WORKSPACE=staging tofu validate
TF_WORKSPACE=production tofu validate

The complete configuration (all files above) passed tofu fmt -check and tofu validate in both workspaces with the modules and providers pinned as shown, after tofu init -backend=false, and again with media_mode = "efs" and a given OIDC provider ARN; in the default workspace validation fails with the intended "does not identify an element" error. It was never planned or applied. A first apply takes a long time for the control plane, a Multi-AZ database, the OpenSearch domain and the broker (each is created in the background for many minutes), and certificate validation waits for DNS.

Secrets for the shop

Magento keeps one value that cannot be recreated: the encryption key. Everything it encrypts in the database (payment tokens that modules store, API credentials in the configuration, the two-factor secrets of administrators, saved integration tokens) is unreadable without it, and a different key on one pod means that pod fails to decrypt, or encrypts what the others cannot read. The rule for all of the secrets is the same: identical on every pod, and never generated by a pod. The application secret holds seven keys, and the ExternalSecret of the chart maps each to the Kubernetes Secret key of the same row:

Key in pocketcoffee/prod/magento Kubernetes Secret key and environment variable Used by

db_app_password

MAGENTO_DB_PASSWORD

Web and Admin pods, as mg_app

db_migrate_password

MAGENTO_MIGRATE_DB_PASSWORD

Cron, consumers and the install, migrate and reindex workloads, as mg_migrate (the chart passes it to them as MAGENTO_DB_PASSWORD)

crypt_key

MAGENTO_CRYPT_KEY

crypt/key of env.php; the install Job’s --key

opensearch_password

MAGENTO_OPENSEARCH_PASSWORD

The system node of env.php (search settings)

valkey_cache_auth_token

MAGENTO_VALKEY_CACHE_AUTH_TOKEN

The cache backend

valkey_session_auth_token

MAGENTO_VALKEY_SESSION_AUTH_TOKEN

The session handler

amqp_password

MAGENTO_AMQP_PASSWORD

The queue node of env.php; KEDA reads the management API with it

The encryption key is handled differently from the rest, and nothing on this page shows a value for it:

  • An existing shop takes the key from the crypt/key entry of its current app/etc/env.php, written to a file that only you can read, and gives that file to the seeding script (see Cloud: Migrating from a Single Server).

  • A new shop needs a key before the install Job can run, because the Job passes it with --key. The installer generates one when the option is missing: CryptKeyGenerator in Magento\Setup\Model returns the prefix base64 followed by the base64 text of 32 random bytes (the length of a libsodium key), and ConfigGenerator uses it only when no key is given and none is configured. Generate the key once, outside the cluster, with that class or with the installer on a disposable installation, write it to a file readable only by you and give the file to the script. Do not generate it in a pod.

  • The format allows several keys. Magento\Framework\Encryption\Encryptor splits the setting on whitespace, treats the last entry as the newest and decrypts older values with the earlier ones, which is how bin/magento encryption:key:change rotates the key. That command writes the new key to env.php, which is read-only here, so a rotation is a deliberate procedure: run the command in a throwaway pod with a writable copy of app/etc (the pattern of the install Job below), copy the resulting crypt/key value into the secret, then restart the workloads. The command re-encrypts the database and may take long on a large one; take a database snapshot first. This procedure was read from the source and not run.

The script below creates the two secrets. It refuses to overwrite an existing secret, takes the generated infrastructure passwords from the OpenTofu outputs, generates the two database passwords (letters and digits only), reads the crypt key from the file and passes the payload on standard input, so that no secret appears in the process list or in a file. It was checked with shellcheck and bash -n; it was not run, because that needs an AWS account and a state.

#!/usr/bin/env bash
# Writes the application secret pocketcoffee/<env>/magento (the seven JSON keys the chart maps) and the
# first administrator's password pocketcoffee/<env>/magento-install. Run once per environment by a person
# with secretsmanager:PutSecretValue and read access to the OpenTofu state; nothing is printed or stored
# in a file. The crypt key is not generated here: it comes from a file you control.
set -euo pipefail

env="${1:?usage: seed-secrets.sh <staging|prod> <crypt-key-file>}"
crypt_key_file="${2:?usage: seed-secrets.sh <staging|prod> <crypt-key-file>}"
secret_id="pocketcoffee/${env}/magento"
install_id="pocketcoffee/${env}/magento-install"

# Letters and digits only: the OpenSearch and RabbitMQ passwords travel inside URLs.
gen() { openssl rand -base64 96 | tr -d '+/=\n' | cut -c1-"${1:-40}"; }

# Refuse to overwrite: a new crypt key makes every encrypted value in the database unreadable.
if aws secretsmanager get-secret-value --secret-id "$secret_id" \
  --query SecretString --output text >/dev/null 2>&1; then
  echo "${secret_id} already has a value; rotate it deliberately." >&2
  exit 1
fi
[ -s "$crypt_key_file" ] || { echo "empty or missing crypt key file" >&2; exit 1; }

tf() { tofu -chdir=infra output "$@"; }

payload="$(
  jq -n \
    --rawfile crypt "$crypt_key_file" \
    --arg db_app "$(gen 40)" --arg db_migrate "$(gen 40)" \
    --arg search "$(tf -raw opensearch_master_password)" \
    --arg vcache "$(tf -json valkey_auth_tokens | jq -r .cache)" \
    --arg vsession "$(tf -json valkey_auth_tokens | jq -r .session)" \
    --arg amqp "$(tf -raw amqp_password)" \
    '{db_app_password: $db_app, db_migrate_password: $db_migrate,
      crypt_key: ($crypt | rtrimstr("\n")),
      opensearch_password: $search,
      valkey_cache_auth_token: $vcache, valkey_session_auth_token: $vsession,
      amqp_password: $amqp}'
)"

# The value goes in on standard input, so it never appears in the process list.
printf '%s' "$payload" | aws secretsmanager put-secret-value \
  --secret-id "$secret_id" --secret-string file:///dev/stdin >/dev/null
jq -n --arg p "$(gen 24)" '{ADMIN_PASSWORD: $p}' | aws secretsmanager put-secret-value \
  --secret-id "$install_id" --secret-string file:///dev/stdin >/dev/null
echo "Seeded ${secret_id} and ${install_id}"

Run it as ./scripts/seed-secrets.sh prod ~/crypt-key.txt from the repository root, then create the two database users with the passwords it generated (read them from the secret inside a trusted shell; the SQL is on the database page) and delete the key file. The passwords therefore exist in Secrets Manager and in the database, and the crypt key in Secrets Manager and wherever you keep your own record of it: keep that record, because the secret has a recovery window of 30 days after deletion and the database cannot be read without the key.

env.php from the environment

app/etc/env.php is the deployment configuration that the installer writes once, for one server. In a cluster the file is rebuilt from the environment, so every pod builds the same configuration from the same sources and no pod ever writes it. Magento also supports environment variables natively: any node of env.php can be given as MAGENTO_DC_ followed by the path with __ between the keys, and MAGENTO_DC__OVERRIDE takes a JSON document that replaces whole sections (see Adobe’s env.php reference); env.php has priority over the variables. This page does not use that mechanism, because it needs one variable per leaf and the file below is easier to read, to lint and to compare with the installation page. The file is mounted read-only from a ConfigMap at app/etc/env.php with subPath; app/etc/config.php stays in the image.

<?php
/**
 * app/etc/env.php for Kubernetes: the installer's file, rebuilt from the environment.
 * Plain settings come from a ConfigMap, secrets from a Secret that External Secrets Operator
 * creates from Secrets Manager. No pod ever writes this file (it is mounted read-only).
 */

/** Read a variable; without a default, a missing value stops the process. */
$pc = static function (string $name, ?string $default = null): string {
    $value = getenv($name);
    if (false !== $value && '' !== $value) {
        return $value;
    }
    if (null !== $default) {
        return $default;
    }
    error_log("env.php: missing environment variable $name");
    throw new RuntimeException('Configuration error');
};

// OpenSearch needs the scheme in the host name: Magento builds the URL from it.
$searchHost = $pc('MAGENTO_OPENSEARCH_HOST');
if (!preg_match('#^https?://#', $searchHost)) {
    $searchHost = 'https://' . $searchHost;
}

// Varnish servers that receive the purge requests: "host:port,host:port".
$varnish = [];
foreach (array_filter(array_map('trim', explode(',', $pc('MAGENTO_VARNISH_HOSTS', '')))) as $entry) {
    [$host, $port] = array_pad(explode(':', $entry, 2), 2, '80');
    $varnish[] = ['host' => $host, 'port' => $port];
}

$db = [
    'host' => $pc('MAGENTO_DB_HOST'),
    'port' => $pc('MAGENTO_DB_PORT', '3306'),
    'dbname' => $pc('MAGENTO_DB_NAME'),
    'username' => $pc('MAGENTO_DB_USER'),
    'password' => $pc('MAGENTO_DB_PASSWORD'),
    'model' => 'mysql4',
    'engine' => 'innodb',
    'active' => '1',
];
// TLS to the database: the CA bundle must be in the image (see the database page).
$dbCa = $pc('MAGENTO_DB_SSL_CA', '');
if ('' !== $dbCa) {
    $db['driver_options'] = [
        (\PHP_VERSION_ID >= 80400 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => $dbCa,
        1014 => true, // verify the server certificate
    ];
}

$valkey = static fn (string $host): array => [
    'server' => 'tls://' . $host,
    'port' => $pc('MAGENTO_VALKEY_PORT', '6379'),
    'timeout' => '2.5',
];

$config = [
    'MAGE_MODE' => $pc('MAGENTO_MODE', 'production'),
    'backend' => ['frontName' => $pc('MAGENTO_BACKEND_FRONTNAME')],
    // One key for the life of the shop, identical on every pod; the secret holds it, never a file.
    'crypt' => ['key' => $pc('MAGENTO_CRYPT_KEY')],
    'db' => [
        'table_prefix' => '',
        'connection' => ['default' => $db],
    ],
    'resource' => ['default_setup' => ['connection' => 'default']],
    'x-frame-options' => 'SAMEORIGIN',
    'directories' => ['document_root_is_pub' => true],
    // The installer's date; a fixed value, because nothing may rewrite this file. Magento tests only that it exists.
    'install' => ['date' => 'Thu, 29 Oct 2026 00:00:00 +0000'],
    'downloadable_domains' => [parse_url($pc('MAGENTO_BASE_URL'), PHP_URL_HOST)],

    // Cache: Valkey database 0 of the cache group. The full page cache is Varnish, not Valkey.
    'cache' => [
        'frontend' => [
            'default' => [
                'id_prefix' => 'pc_',
                'backend' => 'valkey',
                'backend_options' => $valkey($pc('MAGENTO_VALKEY_CACHE_HOST')) + [
                    'database' => '0',
                    'password' => $pc('MAGENTO_VALKEY_CACHE_AUTH_TOKEN'),
                    'compress_data' => '1',
                    'compression_lib' => 'gzip',
                    'use_lua' => '0',
                ],
            ],
        ],
        'graphql' => ['id_salt' => $pc('MAGENTO_GRAPHQL_ID_SALT')],
        'allow_parallel_generation' => false,
    ],
    'http_cache_hosts' => $varnish,

    // A missing entry means "disabled"; keep this list equal to bin/magento cache:status.
    'cache_types' => array_fill_keys([
        'config', 'layout', 'block_html', 'collections', 'reflection', 'db_ddl', 'compiled_config',
        'eav', 'customer_notification', 'config_integration', 'config_integration_api',
        'graphql_query_resolver_result', 'full_page', 'config_webservice', 'translate',
    ], 1),

    // Sessions: the other Valkey group. The handler is "redis" with a Valkey server, as on the installation page.
    'session' => [
        'save' => 'redis',
        'redis' => [
            'host' => 'tls://' . $pc('MAGENTO_VALKEY_SESSION_HOST'),
            'port' => $pc('MAGENTO_VALKEY_PORT', '6379'),
            'password' => $pc('MAGENTO_VALKEY_SESSION_AUTH_TOKEN'),
            'timeout' => '2.5',
            'database' => '0',
            'max_concurrency' => '12',
            'break_after_frontend' => '5',
            'break_after_adminhtml' => '30',
            'first_lifetime' => '600',
            'bot_first_lifetime' => '60',
            'bot_lifetime' => '7200',
            'disable_locking' => '0',
            'min_lifetime' => '60',
            'max_lifetime' => '2592000',
        ],
    ],

    // Queues: Amazon MQ for RabbitMQ over TLS. cron_consumers_runner is off: the consumers are Deployments.
    'queue' => [
        'default_connection' => 'amqp',
        'amqp' => [
            'host' => $pc('MAGENTO_AMQP_HOST'),
            'port' => $pc('MAGENTO_AMQP_PORT', '5671'),
            'user' => $pc('MAGENTO_AMQP_USER'),
            'password' => $pc('MAGENTO_AMQP_PASSWORD'),
            'virtualhost' => $pc('MAGENTO_AMQP_VHOST', '/'),
            'ssl' => true,
            'ssl_options' => ['verify_peer' => true, 'verify_peer_name' => true],
        ],
        'consumers_wait_for_messages' => 1,
    ],
    'cron_consumers_runner' => ['cron_run' => false],
    'lock' => ['provider' => 'db', 'config' => ['prefix' => '']],

    // Configuration that is environment-specific, locked here so that the Admin cannot change it.
    'system' => [
        'default' => [
            'web' => [
                'unsecure' => ['base_url' => $pc('MAGENTO_BASE_URL')],
                'secure' => [
                    'base_url' => $pc('MAGENTO_BASE_URL'),
                    'use_in_frontend' => '1',
                    'use_in_adminhtml' => '1',
                    'offloader_header' => 'X-Forwarded-Proto',
                ],
            ],
            'system' => [
                'full_page_cache' => ['caching_application' => '2'],
                'media_storage_configuration' => ['media_database' => '0'],
            ],
            'catalog' => [
                'search' => [
                    'engine' => 'opensearch',
                    'opensearch_server_hostname' => $searchHost,
                    'opensearch_server_port' => $pc('MAGENTO_OPENSEARCH_PORT', '443'),
                    'opensearch_enable_auth' => '1',
                    'opensearch_username' => $pc('MAGENTO_OPENSEARCH_USER'),
                    'opensearch_password' => $pc('MAGENTO_OPENSEARCH_PASSWORD'),
                    'opensearch_index_prefix' => $pc('MAGENTO_OPENSEARCH_PREFIX'),
                    'opensearch_server_timeout' => '15',
                ],
            ],
        ],
    ],
];

// Media on S3 (remote storage): the pods' IAM role supplies the credentials, so there is no key here.
if ('' !== $pc('MAGENTO_S3_BUCKET', '')) {
    $config['remote_storage'] = [
        'driver' => 'aws-s3',
        'prefix' => '',
        'config' => [
            'bucket' => $pc('MAGENTO_S3_BUCKET'),
            'region' => $pc('MAGENTO_S3_REGION', 'eu-west-1'),
        ],
    ];
}
$mediaUrl = $pc('MAGENTO_MEDIA_BASE_URL', '');
if ('' !== $mediaUrl) {
    $config['system']['default']['web']['unsecure']['base_media_url'] = $mediaUrl;
    $config['system']['default']['web']['secure']['base_media_url'] = $mediaUrl;
}

return $config;

The file was checked with php -l and executed in a PHP 8.5.12 container with test values: it returned the expected nodes (the database connection with the CA option when MAGENTO_DB_SSL_CA is set, the tls:// server names, the Varnish hosts split into host and port, the OpenSearch host with its scheme, the S3 driver and the media URL), and a missing variable stopped it with a line in the log. Points that matter:

  • The nodes are those the installer writes, with the Kubernetes changes. crypt, db, resource, backend, cache, cache_types, session, queue, lock, x-frame-options, MAGE_MODE, install and downloadable_domains are the nodes of a normal installation; directories/document_root_is_pub makes pub the document root.

  • The GraphQL salt must be fixed. Magento\GraphQlCache\Model\CacheId\CacheIdCalculator reads cache/graphql/id_salt and, when it is missing, generates a salt and saves it to env.php: on a read-only file that is a failure, and on a writable one every pod would invent its own. The value is a cache-key salt, not a credential, so it comes from the values (shop.graphqlIdSalt).

  • cache_types is a list of what is enabled, and a missing entry means disabled. Magento\Framework\App\Cache\State reads the node and treats an unknown type as off, and cache:enable and the installer persist the node by writing env.php, which no pod can do. The list in the file is the usual set; produce yours with bin/magento cache:status on a finished installation and add the types of your modules (Pocket Coffee’s PocketCoffee_RoastAdmin adds one), because a type missing here is a cache that is silently off.

  • The search settings live in the system node. Values in that node win over the database and lock the field in the Admin. The OpenSearch host needs its scheme, because the client builds the URL from it and defaults to http when there is none (the file adds https://); the port is 443; the user and password make Magento embed user:password@ in the URL.

  • Both Valkey groups use TLS through the tls:// prefix. The cache backend and the session handler use the Credis client, which takes the scheme from the host name (tls://host), so the setting needs no extra option. The cache uses the backend identifier valkey and the session handler is redis with a Valkey server, the combination that the installation page found to work on Mage-OS 3.6.0 (the valkey session option set was ignored by that installer); test the session handler of your edition by logging in and looking for sess_ keys in the session group. max_concurrency of 12 is a little over 10 percent of the 24 PHP-FPM children of one pod, the rule of thumb Adobe gives for the session lock.

  • http_cache_hosts lists every Varnish pod by name. Magento sends a PURGE request to each, and the names come from the headless Service of the StatefulSet (the chart builds the list). The full page cache application is Varnish (caching_application is 2).

  • Queues. default_connection is amqp, ssl is on with certificate and host name verification (the AMQP factory of 2.4.9 reads ssl and ssl_options such as cafile and verify_peer), and cron_consumers_runner/cron_run is off, because the consumers are Deployments and not processes that cron spawns.

  • Media. With MAGENTO_S3_BUCKET set, the remote_storage node selects the S3 driver with a bucket and a region and no credentials; without it, media stays on the file system (mode efs). The Admin setting system/media_storage_configuration/media_database is locked to 0 as Adobe’s remote storage page requires. MAGENTO_MEDIA_BASE_URL points the media base URLs at the CloudFront host.

  • install/date is a fixed value. Magento treats the application as installed when the node exists; nothing reads the date for behaviour, but nothing may rewrite the file either.

  • A missing variable stops the process with a log line, so a pod without its secrets fails instead of serving a store that cannot decrypt anything.

Cluster prerequisites

Once per cluster, outside the chart: the namespace with the restricted Pod Security level (the pods below run as non-root with all capabilities dropped, a read-only root file system and the default seccomp profile, which satisfies it), the External Secrets Operator with its ClusterSecretStore named aws-secretsmanager, the AWS Load Balancer Controller with the Gateway that the routes attach to, and KEDA for the consumers' autoscaling.

apiVersion: v1
kind: Namespace
metadata:
  name: pocketcoffee
  labels:
    pod-security.kubernetes.io/enforce: restricted
---
# No auth block: the operator uses the credentials of its own Pod Identity association.
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: aws-secretsmanager
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1

The Operator is installed with its Helm chart into the namespace external-secrets, where the Pod Identity association of eks.tf already expects its service account, and KEDA with its own chart (helm repo add kedacore https://kedacore.github.io/charts, then helm install keda kedacore/keda -n keda --create-namespace). The Gateway with its LoadBalancerConfiguration (the HTTPS listener with the ACM certificate from the output site_certificate_arn and the web ACL from waf_acl_arn) and the TargetGroupConfiguration are listed with their fields on Deploying Microservices on EKS, because they belong to the platform and not to one application, and the field names of those controller resources change between controller versions. The chart’s routes name the Gateway by httpRoute.parentRef. The two manifests above passed kubeconform -strict -ignore-missing-schemas; the ClusterSecretStore is skipped, because its schema is not in the default catalogue, so a typo in a custom resource is caught only by the cluster. A cluster that still uses an Ingress instead (ingress-nginx was retired in March 2026; the AWS controller’s Ingress is the alternative) uses the Ingress shown on the WordPress page with the same Services.

The Helm chart

Kubernetes objects of the release: the load balancer with WAF and Gateway, the Varnish StatefulSet that receives customer traffic, the web and optional admin Deployments with nginx and php-fpm containers and per-pod scratch volumes, the Admin route that bypasses Varnish, consumers with KEDA, cron, and the install, migrate and reindex Jobs, all using the managed services outside the cluster

The chart lives in pocketcoffee-magento/, with the helper prefix pc. and one template file per object kind:

pocketcoffee-magento/
  Chart.yaml  values.yaml
  files/env.php  files/default.conf  files/php-fpm.conf  files/php.ini  files/default.vcl
  templates/_helpers.tpl _web.tpl serviceaccount.yaml configmap.yaml externalsecret.yaml media-volume.yaml
            deployment-web.yaml service.yaml varnish.yaml httproute.yaml hpa-pdb.yaml
            consumers.yaml cronjob.yaml job-migrate.yaml job-install.yaml job-reindex.yaml

It was checked with helm lint (no failures) and with helm template | kubeconform -strict -ignore-missing-schemas -summary against the Kubernetes 1.36 schemas, in four configurations. With the defaults, 26 objects render, 19 are valid and 7 are skipped (the ExternalSecret, three HTTPRoutes and three KEDA ScaledObjects are custom resources). With install.enabled=true, 24 render, 19 are valid and 5 skipped. With the Admin Deployment, the reindex Job and Varnish off, 25 render and 18 are valid. With media.mode=efs, KEDA, the HPA and cron.groups off, 24 render and 20 are valid. A typo in a custom resource is therefore caught only by the cluster. files/default.vcl is a placeholder in these checks; see the Varnish section.

apiVersion: v2
name: pocketcoffee-magento
description: Magento on Kubernetes - nginx and PHP-FPM, optional Admin Deployment, Varnish, queue consumers, cron, install and migrate Jobs
type: application
version: 0.1.0
appVersion: "2.4.9"
kubeVersion: ">=1.34.0-0"
image:
  repository: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/pocketcoffee/magento
  tag: ""                 # the git SHA, always passed with --set image.tag
  nginxTagSuffix: "-nginx"   # nginx and the static files, same code as the PHP image
  pullPolicy: IfNotPresent

serviceAccount:
  create: true
  name: pocketcoffee-magento    # bound to the IAM role by Pod Identity (media.tf)

shop:
  domain: www.pocketcoffee.example
  adminFrontName: admin_pocketcoffee
  mode: production
  trustedProxies: 10.10.0.0/16    # nginx real_ip: the VPC CIDR (load balancer and Varnish pods)
  graphqlIdSalt: pocketcoffee-graphql-1   # cache/graphql/id_salt: a fixed, non-secret cache-key salt

database:
  host: ""                # output db_endpoint
  port: "3306"
  name: pocketcoffee
  appUser: mg_app
  migrateUser: mg_migrate
  sslCa: ""               # path of the RDS CA bundle in the image; empty = no TLS (see the database page)

opensearch:
  host: ""                # output opensearch_endpoint (host name only)
  port: "443"
  user: magento
  indexPrefix: pocketcoffee

valkey:
  cacheHost: ""           # output valkey_cache_endpoint
  sessionHost: ""         # output valkey_session_endpoint
  port: "6379"

amqp:
  host: ""                # output amqp_host
  port: "5671"
  user: magento
  vhost: /

media:
  mode: s3                # s3 (remote storage) or efs
  bucket: ""              # output media_bucket (mode s3)
  region: eu-west-1
  claimName: pocketcoffee-media   # mode efs
  size: 100Gi             # required by the API, not enforced by EFS
  efs:
    fileSystemId: ""      # output efs_file_system_id
    accessPointId: ""     # output efs_access_point_id
  scratchSize: 2Gi        # pub/media as emptyDir (mode s3): resized images are cached here
  baseUrl: https://media.pocketcoffee.example/media/   # base_media_url, the CloudFront host (mode s3)

secrets:
  enabled: true
  name: pocketcoffee-magento                       # the Kubernetes Secret (ExternalSecret target)
  storeName: aws-secretsmanager                    # ClusterSecretStore
  remoteKey: pocketcoffee/prod/magento             # Secrets Manager secret with the seven keys
  installRemoteKey: pocketcoffee/prod/magento-install   # ADMIN_PASSWORD, only while install.enabled
  refreshInterval: 1h

web:
  replicas: 2
  terminationGracePeriodSeconds: 40
  phpFpm:
    resources:
      requests: { cpu: "1", memory: 1536Mi }
      limits: { memory: 2Gi }
  nginx:
    resources:
      requests: { cpu: 100m, memory: 64Mi }
      limits: { memory: 128Mi }

# Optional second Deployment, same image and pod template, reached only by the Admin route.
admin:
  enabled: false
  replicas: 2
  resources:
    requests: { cpu: "1", memory: 1536Mi }
    limits: { memory: 2Gi }

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

pdb:
  maxUnavailable: 1

varnish:
  enabled: true
  replicas: 2
  image:
    repository: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/pocketcoffee/varnish
    tag: "8.0.2"
  size: 1g                # VARNISH_SIZE, malloc storage
  backendPort: 8080
  resources:
    requests: { cpu: 250m, memory: 1280Mi }
    limits: { memory: 1536Mi }

httpRoute:
  enabled: true
  parentRef:
    name: pocketcoffee
    namespace: gateway
  redirectApex: true
  apexHost: pocketcoffee.example

cron:
  enabled: true
  schedule: "* * * * *"
  groups: [default, index]        # one foreground cron:run --group per entry; empty = a plain cron:run
  activeDeadlineSeconds: 900
  resources:
    requests: { cpu: 250m, memory: 1Gi }
    limits: { memory: 2Gi }

consumers:
  - name: async.operations.all
    queue: async.operations.all
    maxMessages: 1000
    minReplicas: 1
    maxReplicas: 4
    messagesPerReplica: 100
  - name: product_action_attribute.update
    queue: product_action_attribute.update
    maxMessages: 1000
    minReplicas: 1
    maxReplicas: 2
    messagesPerReplica: 50
  - name: media.storage.catalog.image.resize
    queue: media.storage.catalog.image.resize
    maxMessages: 1000
    minReplicas: 0
    maxReplicas: 6
    messagesPerReplica: 200

consumer:
  terminationGracePeriodSeconds: 60
  resources:
    requests: { cpu: 250m, memory: 1Gi }
    limits: { memory: 2Gi }

keda:
  enabled: true
  managementUrl: ""       # https://<amqp_host>:443, the broker's management API
  pollingIntervalSeconds: 30
  cooldownPeriodSeconds: 300

migrate:
  enabled: true
  activeDeadlineSeconds: 1800

reindex:
  enabled: false          # render the pocketcoffee-reindex Job (run it on demand)
  indexers: []            # empty = bin/magento indexer:reindex for all indexers
  run: ""                # optional suffix of the Job name, so that a second run does not collide with the first

install:
  enabled: false
  adminFirstname: Pocket
  adminLastname: Admin
  adminEmail: owner@pocketcoffee.example
  adminUser: owner
  language: es_ES
  currency: EUR
  timezone: Europe/Madrid

The values are the contract with the rest of the section: image.repository and image.tag, shop.domain and shop.adminFrontName, web.replicas, admin.enabled, varnish.replicas, the consumers list (name, queue, maxMessages, minReplicas, maxReplicas and, for the KEDA threshold, messagesPerReplica), cron.schedule, database.host and database.name, secrets.name (the Kubernetes Secret, pocketcoffee-magento), media.mode (s3 or efs), media.bucket and media.claimName. image.tag has no default and the chart refuses to render without it, so a deployment can never run "latest". The image repository holds two tags per release: <git-sha> (PHP, the code, read-only) and <git-sha>-nginx (nginx with the same files, so the static assets served by nginx can never differ from the code PHP runs); the Varnish image has its own repository and tag. See the image page. Nginx must be the 1.30 line that Adobe’s table lists for 2.4.9 and an unprivileged build (the container runs as UID 101 with a read-only root file system). shop.trustedProxies is the VPC CIDR: the load balancer and the Varnish pods both appear to nginx from it.

{{- define "pc.name" -}}pocketcoffee-magento{{- end -}}

{{- define "pc.labels" -}}
app.kubernetes.io/name: {{ include "pc.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end -}}

{{/* Selector labels of one component: include "pc.selector" (dict "root" . "component" "web"). */}}
{{- define "pc.selector" -}}
app.kubernetes.io/name: {{ include "pc.name" .root }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}

{{- define "pc.image" -}}
{{ .Values.image.repository }}:{{ required "set image.tag to the git SHA" .Values.image.tag }}
{{- end -}}

{{- define "pc.nginxImage" -}}
{{ include "pc.image" . }}{{ .Values.image.nginxTagSuffix }}
{{- end -}}

{{- define "pc.serviceAccountName" -}}
{{ .Values.serviceAccount.name }}
{{- end -}}

{{/* Plain settings (ConfigMap) and secrets (ExternalSecret target) for every workload. */}}
{{- define "pc.envFrom" -}}
- configMapRef:
    name: {{ .Release.Name }}-env
- secretRef:
    name: {{ .Values.secrets.name }}
{{- end -}}

{{/* Database user and password per workload: include "pc.dbEnv" (dict "root" . "user" "mg_app" "key" "MAGENTO_DB_PASSWORD"). */}}
{{- define "pc.dbEnv" -}}
- name: MAGENTO_DB_USER
  value: {{ .user | quote }}
- name: MAGENTO_DB_PASSWORD
  valueFrom:
    secretKeyRef:
      name: {{ .root.Values.secrets.name }}
      key: {{ .key }}
{{- end -}}

{{- define "pc.containerSecurity" -}}
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
  drop: [ALL]
{{- end -}}

{{- define "pc.podSecurity" -}}
runAsNonRoot: true
fsGroup: 33
seccompProfile:
  type: RuntimeDefault
{{- end -}}

{{/* "pocketcoffee-consumer-" slug: dots and underscores become dashes. */}}
{{- define "pc.consumerSlug" -}}
{{ . | replace "." "-" | replace "_" "-" }}
{{- end -}}

{{/* Writable and configuration mounts of every PHP container: scratch, media, env.php, php.ini. */}}
{{- define "pc.phpMounts" -}}
- { name: tmp, mountPath: /tmp }
- { name: var, mountPath: /var/www/html/var }
- { name: media, mountPath: /var/www/html/pub/media }
- { name: env-php, mountPath: /var/www/html/app/etc/env.php, subPath: env.php, readOnly: true }
- { name: php, mountPath: /usr/local/etc/php/conf.d/zz-pocketcoffee.ini, subPath: php.ini, readOnly: true }
{{- end -}}

{{- define "pc.phpVolumes" -}}
- { name: tmp, emptyDir: { sizeLimit: 256Mi } }
- { name: var, emptyDir: { sizeLimit: 2Gi } }
{{- if eq .Values.media.mode "efs" }}
- name: media
  persistentVolumeClaim:
    claimName: {{ .Values.media.claimName }}
{{- else }}
- { name: media, emptyDir: { sizeLimit: {{ .Values.media.scratchSize }} } }
{{- end }}
- name: env-php
  configMap:
    name: {{ .Release.Name }}-env-php
- name: php
  configMap:
    name: {{ .Release.Name }}-php
{{- end -}}

{{/* Pod DNS names of the Varnish replicas, for the purge requests: "host:port,host:port". */}}
{{- define "pc.varnishHosts" -}}
{{- $hosts := list -}}
{{- range $i := until (int .Values.varnish.replicas) -}}
{{- $hosts = append $hosts (printf "%s-varnish-%d.%s-varnish-headless.%s.svc.cluster.local:6081" $.Release.Name $i $.Release.Name $.Release.Namespace) -}}
{{- end -}}
{{- join "," $hosts -}}
{{- end -}}

The helpers keep the workloads that run the shop (web pods, consumers, cron, Jobs) identical where it matters: the same image, the same environment and the same writable paths. pc.dbEnv is the only difference between them: the web and admin pods use the user and password that envFrom gives them (mg_app), and the cron, consumers and the install, migrate and reindex workloads replace both by mg_migrate and the migrate password, because the indexers truncate and create tables (see Cloud: Managed Database (RDS and Aurora)); an env entry wins over envFrom. All the keys of the Secret are visible to every pod through envFrom, including the migrate password; split the migrate credentials into a second ExternalSecret that only the Jobs mount if that exposure matters in your threat model.

Configuration, secrets and the writable paths

# nginx container of the pod; PHP-FPM listens on 127.0.0.1:9000 in the same pod.
upstream fastcgi_backend {
    server 127.0.0.1:9000;
}

set_real_ip_from {{ .Values.shop.trustedProxies }};
real_ip_header X-Forwarded-For;
real_ip_recursive on;

server {
    listen 8080 default_server;
    server_name _;
    server_tokens off;
    client_max_body_size 32M;

    set $MAGE_ROOT /var/www/html;
    set $MAGE_DEBUG_SHOW_ARGS 0;

    # Liveness: answered by nginx itself, no PHP, no database.
    location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; }
    # Readiness uses /health_check.php of Magento, which the included file already routes to PHP-FPM.

    # The web installer and updater are never reachable in a cluster; ^~ wins over the regexes of the sample.
    location ^~ /setup { return 404; }
    location ^~ /update { return 404; }

    include /var/www/html/nginx.conf.sample;
}

The nginx file keeps Magento’s own routing: it includes nginx.conf.sample from the image (the file of the 2.4.9 code base, which routes index.php, get.php, static.php, the error pages and health_check.php to PHP-FPM and denies the sensitive paths) and adds what a pod needs. PHP-FPM is the upstream fastcgi_backend on 127.0.0.1:9000, the trusted proxy range comes from the values (the file is rendered with Helm’s tpl) so that the client address is the one in X-Forwarded-For, /healthz is answered by nginx itself, and the web setup (/setup) and updater (/update) are answered with 404: the prefix location with ^~ wins over the regular-expression locations of the sample, so the installer’s browser interface is never reachable in a cluster. The rendered file and the real nginx.conf.sample of the 2.4.9 tag passed nginx -t in an nginx:1.29-alpine container, and a started container answered 200 on /healthz and 404 on /setup/index.php, /setup/ and /update/index.php. Two consequences: the -nginx image must contain the pub/*.php entry points (index.php, get.php, static.php, health_check.php), because the PHP location of the sample starts with try_files $uri =404 and answers 404 for a file that nginx cannot see, even though PHP-FPM has it; and the pub/media mount of the nginx container is read-only, so nginx serves media that PHP wrote and Magento’s get.php fallback does the rest.

; Loaded after the image's own pool files, so these values win.
[global]
error_log = /proc/self/fd/2

[www]
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 24
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
request_terminate_timeout = 300s
ping.path = /fpm-ping
ping.response = pong
catch_workers_output = yes
decorate_workers_output = no
; The pod environment (ConfigMap and Secret) must reach the workers: env.php reads it with getenv().
clear_env = no
; Loaded last (zz-), so these values win over the image's own.
memory_limit = 768M
max_execution_time = 120
realpath_cache_size = 10M
realpath_cache_ttl = 7200
opcache.enable = 1
opcache.enable_cli = 1
opcache.validate_timestamps = 0
opcache.memory_consumption = 512
opcache.max_accelerated_files = 100000
opcache.interned_strings_buffer = 32
session.auto_start = 0

The pool file loads after the image’s own and wins. pm.max_children is the number that bounds database connections per pod, and request_terminate_timeout is long because Admin imports and exports run in web requests. clear_env = no is not optional: without it the workers see none of the pod’s variables and env.php stops every request with "missing environment variable". The php.ini file sets what an immutable image needs (opcache.validate_timestamps = 0 and a large file cache; the image page sets the baseline and this ConfigMap changes it per environment) and is mounted into every PHP container, because the consumers and cron are PHP processes too.

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-env
  labels:
    {{- include "pc.labels" . | nindent 4 }}
data:
  MAGENTO_MODE: {{ .Values.shop.mode | quote }}
  MAGENTO_BASE_URL: {{ printf "https://%s/" .Values.shop.domain | quote }}
  MAGENTO_BACKEND_FRONTNAME: {{ .Values.shop.adminFrontName | quote }}
  MAGENTO_GRAPHQL_ID_SALT: {{ .Values.shop.graphqlIdSalt | quote }}
  MAGENTO_DB_HOST: {{ required "set database.host" .Values.database.host | quote }}
  MAGENTO_DB_PORT: {{ .Values.database.port | quote }}
  MAGENTO_DB_NAME: {{ .Values.database.name | quote }}
  MAGENTO_DB_USER: {{ .Values.database.appUser | quote }}
  MAGENTO_MIGRATE_DB_USER: {{ .Values.database.migrateUser | quote }}
  {{- if .Values.database.sslCa }}
  MAGENTO_DB_SSL_CA: {{ .Values.database.sslCa | quote }}
  {{- end }}
  MAGENTO_OPENSEARCH_HOST: {{ required "set opensearch.host" .Values.opensearch.host | quote }}
  MAGENTO_OPENSEARCH_PORT: {{ .Values.opensearch.port | quote }}
  MAGENTO_OPENSEARCH_USER: {{ .Values.opensearch.user | quote }}
  MAGENTO_OPENSEARCH_PREFIX: {{ .Values.opensearch.indexPrefix | quote }}
  MAGENTO_VALKEY_CACHE_HOST: {{ required "set valkey.cacheHost" .Values.valkey.cacheHost | quote }}
  MAGENTO_VALKEY_SESSION_HOST: {{ required "set valkey.sessionHost" .Values.valkey.sessionHost | quote }}
  MAGENTO_VALKEY_PORT: {{ .Values.valkey.port | quote }}
  MAGENTO_AMQP_HOST: {{ required "set amqp.host" .Values.amqp.host | quote }}
  MAGENTO_AMQP_PORT: {{ .Values.amqp.port | quote }}
  MAGENTO_AMQP_USER: {{ .Values.amqp.user | quote }}
  MAGENTO_AMQP_VHOST: {{ .Values.amqp.vhost | quote }}
  MAGENTO_VARNISH_HOSTS: {{ include "pc.varnishHosts" . | quote }}
  {{- if eq .Values.media.mode "s3" }}
  MAGENTO_S3_BUCKET: {{ required "set media.bucket" .Values.media.bucket | quote }}
  MAGENTO_S3_REGION: {{ .Values.media.region | quote }}
  {{- if .Values.media.baseUrl }}
  MAGENTO_MEDIA_BASE_URL: {{ .Values.media.baseUrl | quote }}
  {{- end }}
  {{- end }}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-env-php
  labels:
    {{- include "pc.labels" . | nindent 4 }}
data:
  env.php: |
    {{- .Files.Get "files/env.php" | nindent 4 }}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-nginx
  labels:
    {{- include "pc.labels" . | nindent 4 }}
data:
  default.conf: |
    {{- tpl (.Files.Get "files/default.conf") . | nindent 4 }}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-php
  labels:
    {{- include "pc.labels" . | nindent 4 }}
data:
  php.ini: |
    {{- .Files.Get "files/php.ini" | nindent 4 }}
  php-fpm.conf: |
    {{- .Files.Get "files/php-fpm.conf" | nindent 4 }}
{{- if .Values.varnish.enabled }}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-varnish-vcl
  labels:
    {{- include "pc.labels" . | nindent 4 }}
data:
  default.vcl: |
    {{- .Files.Get "files/default.vcl" | nindent 4 }}
{{- end }}

Five ConfigMaps: pocketcoffee-env (the plain environment variables of the table below), pocketcoffee-env-php (the file above), pocketcoffee-nginx, pocketcoffee-php and pocketcoffee-varnish-vcl. The environment variables are:

Variable Meaning

MAGENTO_BASE_URL, MAGENTO_BACKEND_FRONTNAME, MAGENTO_MODE

https:// plus shop.domain and a trailing slash; the Admin path; production

MAGENTO_DB_HOST, MAGENTO_DB_PORT, MAGENTO_DB_NAME, MAGENTO_DB_USER

The RDS endpoint, 3306, pocketcoffee and mg_app; MAGENTO_MIGRATE_DB_USER is mg_migrate; MAGENTO_DB_SSL_CA only when database.sslCa is set

MAGENTO_OPENSEARCH_HOST, _PORT, _USER, _PREFIX

The domain endpoint (without scheme), 443, magento and the index prefix pocketcoffee

MAGENTO_VALKEY_CACHE_HOST, MAGENTO_VALKEY_SESSION_HOST, MAGENTO_VALKEY_PORT

The two primary endpoints and 6379

MAGENTO_AMQP_HOST, _PORT, _USER, _VHOST

The broker endpoint, 5671, magento and /

MAGENTO_VARNISH_HOSTS

Comma-separated pod DNS names of the Varnish StatefulSet, port 6081

MAGENTO_S3_BUCKET, MAGENTO_S3_REGION, MAGENTO_MEDIA_BASE_URL

Only with media.mode=s3: the bucket, eu-west-1 and media.baseUrl (https://media.pocketcoffee.example/media/)

MAGENTO_GRAPHQL_ID_SALT

The fixed salt for the GraphQL cache identifiers

The Secret’s variables are those of the previous table of seven keys. A change to any of them changes the pod template (a checksum annotation of the ConfigMaps), so the pods roll; a change to the Secret alone is not seen until the pods restart, which is why a secret rotation is a rollout.

{{- if .Values.secrets.enabled }}
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: {{ .Values.secrets.name }}
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  refreshInterval: {{ .Values.secrets.refreshInterval }}
  secretStoreRef:
    kind: ClusterSecretStore
    name: {{ .Values.secrets.storeName }}
  target:
    name: {{ .Values.secrets.name }}
    creationPolicy: Owner
  # One Kubernetes Secret; every key is an environment variable of env.php (see the table in the text).
  data:
    - secretKey: MAGENTO_DB_PASSWORD
      remoteRef: { key: {{ .Values.secrets.remoteKey }}, property: db_app_password }
    - secretKey: MAGENTO_MIGRATE_DB_PASSWORD
      remoteRef: { key: {{ .Values.secrets.remoteKey }}, property: db_migrate_password }
    - secretKey: MAGENTO_CRYPT_KEY
      remoteRef: { key: {{ .Values.secrets.remoteKey }}, property: crypt_key }
    - secretKey: MAGENTO_OPENSEARCH_PASSWORD
      remoteRef: { key: {{ .Values.secrets.remoteKey }}, property: opensearch_password }
    - secretKey: MAGENTO_VALKEY_CACHE_AUTH_TOKEN
      remoteRef: { key: {{ .Values.secrets.remoteKey }}, property: valkey_cache_auth_token }
    - secretKey: MAGENTO_VALKEY_SESSION_AUTH_TOKEN
      remoteRef: { key: {{ .Values.secrets.remoteKey }}, property: valkey_session_auth_token }
    - secretKey: MAGENTO_AMQP_PASSWORD
      remoteRef: { key: {{ .Values.secrets.remoteKey }}, property: amqp_password }
{{- if .Values.install.enabled }}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: {{ .Release.Name }}-install
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  refreshInterval: {{ .Values.secrets.refreshInterval }}
  secretStoreRef:
    kind: ClusterSecretStore
    name: {{ .Values.secrets.storeName }}
  target:
    name: {{ .Release.Name }}-install
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: {{ .Values.secrets.installRemoteKey }}
{{- end }}
{{- end }}

data maps each of the seven JSON keys of the Secrets Manager secret to its environment variable name; the operator creates the Kubernetes Secret pocketcoffee-magento in the namespace. The install secret is created only while install.enabled is set, so the administrator’s password is not left in the cluster.

{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "pc.serviceAccountName" . }}
  labels:
    {{- include "pc.labels" . | nindent 4 }}
# The IAM role comes from the Pod Identity association of the infrastructure, not from an annotation.
automountServiceAccountToken: false
{{- end }}
{{- if eq .Values.media.mode "efs" }}
# Static provisioning: the PV names the EFS access point from OpenTofu (UID/GID 33).
apiVersion: v1
kind: PersistentVolume
metadata:
  name: {{ .Release.Namespace }}-{{ .Values.media.claimName }}
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  capacity:
    storage: {{ .Values.media.size }}
  accessModes: [ReadWriteMany]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  csi:
    driver: efs.csi.aws.com
    volumeHandle: {{ required "set media.efs.fileSystemId" .Values.media.efs.fileSystemId }}::{{ required "set media.efs.accessPointId" .Values.media.efs.accessPointId }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: {{ .Values.media.claimName }}
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  accessModes: [ReadWriteMany]
  storageClassName: ""
  volumeName: {{ .Release.Namespace }}-{{ .Values.media.claimName }}
  resources:
    requests:
      storage: {{ .Values.media.size }}
{{- end }}

The service account is the one the Pod Identity association names; the pods have no token mounted (automountServiceAccountToken: false), because the Pod Identity agent injects its own credentials and a pod never calls the Kubernetes API. The volume file renders only with media.mode=efs: static provisioning, where the volume handle is <file-system-id>::<access-point-id>, so the CSI driver mounts the access point from OpenTofu (UID and GID 33). Retain keeps the media when the release is deleted. In mode s3 the pods get an emptyDir at pub/media, where Magento caches resized images, as Adobe’s remote storage page says the integration relies on local writes there.

The web Deployments

{{ include "pc.webDeployment" (dict "root" . "component" "web" "replicas" .Values.web.replicas "resources" .Values.web.phpFpm.resources "hpa" .Values.autoscaling.enabled) }}
{{- if .Values.admin.enabled }}
---
{{ include "pc.webDeployment" (dict "root" . "component" "admin" "replicas" .Values.admin.replicas "resources" .Values.admin.resources "hpa" false) }}
{{- end }}
{{/* One web-tier Deployment: include "pc.webDeployment" (dict "root" . "component" "web" "replicas" 2 "resources" ... "hpa" true). */}}
{{- define "pc.webDeployment" -}}
{{- $root := .root -}}
{{- $c := .component -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ $root.Release.Name }}-{{ $c }}
  labels:
    {{- include "pc.labels" $root | nindent 4 }}
    app.kubernetes.io/component: {{ $c }}
spec:
  {{- if $root.Values.install.enabled }}
  replicas: 0   # no web pod while the installer runs
  {{- else if not .hpa }}
  replicas: {{ .replicas }}
  {{- end }}
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0
  selector:
    matchLabels:
      {{- include "pc.selector" (dict "root" $root "component" $c) | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "pc.selector" (dict "root" $root "component" $c) | nindent 8 }}
      annotations:
        checksum/config: {{ include (print $root.Template.BasePath "/configmap.yaml") $root | sha256sum }}
    spec:
      serviceAccountName: {{ include "pc.serviceAccountName" $root }}
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: {{ $root.Values.web.terminationGracePeriodSeconds }}
      securityContext:
        {{- include "pc.podSecurity" $root | nindent 8 }}
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              {{- include "pc.selector" (dict "root" $root "component" $c) | nindent 14 }}
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              {{- include "pc.selector" (dict "root" $root "component" $c) | nindent 14 }}
      containers:
        - name: php-fpm
          image: {{ include "pc.image" $root }}
          imagePullPolicy: {{ $root.Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" $root | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          ports:
            - name: fastcgi
              containerPort: 9000
          envFrom:
            {{- include "pc.envFrom" $root | nindent 12 }}
          livenessProbe:
            tcpSocket:
              port: fastcgi
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "10"]
          resources:
            {{- toYaml .resources | nindent 12 }}
          volumeMounts:
            {{- include "pc.phpMounts" $root | nindent 12 }}
            - name: php
              mountPath: /usr/local/etc/php-fpm.d/zz-pocketcoffee.conf
              subPath: php-fpm.conf
              readOnly: true
        - name: nginx
          image: {{ include "pc.nginxImage" $root }}
          imagePullPolicy: {{ $root.Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" $root | nindent 12 }}
            runAsUser: 101
            runAsGroup: 101
          ports:
            - name: http
              containerPort: 8080
          # Magento's own check: it opens the database and every cache backend of env.php.
          startupProbe:
            httpGet:
              path: /health_check.php
              port: http
            periodSeconds: 5
            failureThreshold: 36
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health_check.php
              port: http
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "10"]
          resources:
            {{- toYaml $root.Values.web.nginx.resources | nindent 12 }}
          volumeMounts:
            - { name: nginx-tmp, mountPath: /tmp }
            - { name: media, mountPath: /var/www/html/pub/media, readOnly: true }
            - name: nginx
              mountPath: /etc/nginx/conf.d/default.conf
              subPath: default.conf
              readOnly: true
      volumes:
        {{- include "pc.phpVolumes" $root | nindent 8 }}
        - { name: nginx-tmp, emptyDir: { sizeLimit: 128Mi } }
        - name: nginx
          configMap:
            name: {{ $root.Release.Name }}-nginx
{{- end -}}

pocketcoffee-web and, with admin.enabled=true, pocketcoffee-admin come from one template, so the Admin pods are the same image and configuration with their own replica count, resources and Service. What the template does and why:

  • Two containers, one pod. nginx serves the static files baked into the -nginx image and passes .php to PHP-FPM on 127.0.0.1:9000. The pods are spread over zones with a hard constraint and over nodes with a soft one, so losing a zone loses at most half of the capacity. maxUnavailable: 0 makes a rollout add a pod before it removes one.

  • Probes: health_check.php for readiness, a static file for liveness. Magento’s pub/health_check.php (read in the 2.4.9 source) boots the application, opens a connection for every entry of db/connection and creates and tests every cache frontend of env.php, answering 500 on any failure. That is exactly the readiness question ("can this pod serve a page?"), and the startup probe gives a cold pod three minutes. It is also the reason it is not the liveness probe: a database failover would make every pod fail liveness together and restart the whole tier at the moment a retry would have worked, so liveness is nginx’s own /healthz and a TCP check of PHP-FPM. The cost of this readiness probe is that a failover of the database or of the cache group makes all pods unready for its duration (the load balancer then has no target, and the error page is the load balancer’s); if that is the wrong trade for you, point readiness at /healthz and alarm on the 5xx rate instead. Each probe boots Magento, so the period is ten seconds.

  • Writable paths. The root file system is read-only. /tmp, var and pub/media (mode s3) are per-pod emptyDir volumes; generated/ (the compiled dependency injection) and pub/static (the deployed static content) are baked into the image and never written in production mode, and app/etc is not writable at all: env.php is a read-only file mount and config.php is in the image. A module that writes elsewhere fails with "read-only file system" at the first write, which is how you find it before production does. fsGroup: 33 makes the emptyDir volumes writable for www-data.

  • preStop sleep and terminationGracePeriodSeconds. Ten seconds of sleep let the load balancer deregister the pod before nginx stops, and the grace period covers it plus the longest request.

  • No warm-up init container. The configuration and the caches are not per pod: the cache is in Valkey, and the compiled code is in the image. The first request on a cold pod pays for the opcache and the Valkey connection, which the startup probe absorbs.

  • The configuration checksum. A changed ConfigMap changes the pod template, so the pods roll.

Services, Varnish, routes, autoscaling and disruption budgets

apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-web
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  type: ClusterIP
  selector:
    {{- include "pc.selector" (dict "root" . "component" "web") | nindent 4 }}
  ports:
    - name: http
      port: 8080
      targetPort: http
{{- if .Values.admin.enabled }}
---
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-admin
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  type: ClusterIP
  selector:
    {{- include "pc.selector" (dict "root" . "component" "admin") | nindent 4 }}
  ports:
    - name: http
      port: 8080
      targetPort: http
{{- end }}
{{- if .Values.varnish.enabled }}
# Customer traffic enters here (port 80 to the Varnish port 6081).
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-varnish
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  type: ClusterIP
  selector:
    {{- include "pc.selector" (dict "root" . "component" "varnish") | nindent 4 }}
  ports:
    - name: http
      port: 80
      targetPort: http
---
# Stable pod DNS names, so that Magento can send a purge to every replica.
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-varnish-headless
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  clusterIP: None
  selector:
    {{- include "pc.selector" (dict "root" . "component" "varnish") | nindent 4 }}
  ports:
    - name: http
      port: 6081
      targetPort: http
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: {{ .Release.Name }}-varnish
  labels:
    {{- include "pc.labels" . | nindent 4 }}
    app.kubernetes.io/component: varnish
spec:
  serviceName: {{ .Release.Name }}-varnish-headless
  # A StatefulSet only for the pod names: the cache is memory-only and starts empty.
  replicas: {{ ternary 0 .Values.varnish.replicas .Values.install.enabled }}
  podManagementPolicy: Parallel
  selector:
    matchLabels:
      {{- include "pc.selector" (dict "root" . "component" "varnish") | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "pc.selector" (dict "root" . "component" "varnish") | nindent 8 }}
      annotations:
        checksum/vcl: {{ .Files.Get "files/default.vcl" | sha256sum }}
    spec:
      serviceAccountName: {{ include "pc.serviceAccountName" . }}
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              {{- include "pc.selector" (dict "root" . "component" "varnish") | nindent 14 }}
      containers:
        - name: varnish
          image: {{ .Values.varnish.image.repository }}:{{ .Values.varnish.image.tag }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
          # The official image's entrypoint builds the varnishd command line from these variables.
          env:
            - { name: VARNISH_HTTP_PORT, value: "6081" }
            - { name: VARNISH_SIZE, value: {{ .Values.varnish.size | quote }} }
          ports:
            - name: http
              containerPort: 6081
          readinessProbe:
            tcpSocket:
              port: http
            periodSeconds: 5
          livenessProbe:
            tcpSocket:
              port: http
            periodSeconds: 10
            failureThreshold: 6
          resources:
            {{- toYaml .Values.varnish.resources | nindent 12 }}
          volumeMounts:
            - { name: vcl, mountPath: /etc/varnish/default.vcl, subPath: default.vcl, readOnly: true }
            - { name: workdir, mountPath: /var/lib/varnish }
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: vcl
          configMap:
            name: {{ .Release.Name }}-varnish-vcl
        - { name: workdir, emptyDir: { medium: Memory, sizeLimit: 128Mi } }
        - { name: tmp, emptyDir: { sizeLimit: 64Mi } }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ .Release.Name }}-varnish
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      {{- include "pc.selector" (dict "root" . "component" "varnish") | nindent 6 }}
{{- end }}

The Service pocketcoffee-web listens on 8080, the port the VCL’s backend names (pocketcoffee-web:8080). Varnish is a StatefulSet for one reason only, stable pod DNS names: Magento sends a PURGE to every address of http_cache_hosts, and a Deployment behind a ClusterIP Service would deliver each purge to one pod only. The headless Service pocketcoffee-varnish-headless gives pod n the name pocketcoffee-varnish-n.pocketcoffee-varnish-headless.pocketcoffee.svc.cluster.local, and the chart builds MAGENTO_VARNISH_HOSTS from the replica count. The cache itself is memory only (VARNISH_SIZE, malloc storage) and starts empty after every restart; the StatefulSet has no volume claim. The official varnish image builds its command line from environment variables (VARNISH_HTTP_PORT, VARNISH_SIZE, the VCL at /etc/varnish/default.vcl), and the container below was run read-only, without capabilities, as UID 1000 with a writable and executable /var/lib/varnish (a noexec volume is refused by varnishd) before the manifest was written. Customer traffic goes through the Service pocketcoffee-varnish (80 to 6081). The VCL comes from bin/magento varnish:vcl:generate at build time with the backend pocketcoffee-web:8080 and the purge ACL of the pod network, is committed next to the chart as files/default.vcl and lands in the ConfigMap; the listing contains a placeholder that satisfies the chart checks, the real file, the Varnish version and its export option, the purge ACL and the offloader_header behaviour are on Cloud: Varnish and Full Page Cache on Kubernetes. Adobe’s 2.4.9 table lists Varnish 8.

{{- if .Values.httpRoute.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .Release.Name }}-www
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  parentRefs:
    - name: {{ .Values.httpRoute.parentRef.name }}
      namespace: {{ .Values.httpRoute.parentRef.namespace }}
  hostnames:
    - {{ .Values.shop.domain }}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: {{ .Release.Name }}-{{ if .Values.varnish.enabled }}varnish{{ else }}web{{ end }}
          port: {{ if .Values.varnish.enabled }}80{{ else }}8080{{ end }}
---
# The Admin bypasses Varnish: nothing in it may be cached. The longer prefix wins over "/".
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .Release.Name }}-admin
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  parentRefs:
    - name: {{ .Values.httpRoute.parentRef.name }}
      namespace: {{ .Values.httpRoute.parentRef.namespace }}
  hostnames:
    - {{ .Values.shop.domain }}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /{{ .Values.shop.adminFrontName }}
      backendRefs:
        - name: {{ .Release.Name }}-{{ if .Values.admin.enabled }}admin{{ else }}web{{ end }}
          port: 8080
{{- if .Values.httpRoute.redirectApex }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .Release.Name }}-apex
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  parentRefs:
    - name: {{ .Values.httpRoute.parentRef.name }}
      namespace: {{ .Values.httpRoute.parentRef.namespace }}
  hostnames:
    - {{ .Values.httpRoute.apexHost }}
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            hostname: {{ .Values.shop.domain }}
            statusCode: 301
{{- end }}
{{- end }}
{{- if and .Values.autoscaling.enabled (not .Values.install.enabled) }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ .Release.Name }}-web
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ .Release.Name }}-web
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
---
{{- end }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ .Release.Name }}-web
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  maxUnavailable: {{ .Values.pdb.maxUnavailable }}
  selector:
    matchLabels:
      {{- include "pc.selector" (dict "root" . "component" "web") | nindent 6 }}
{{- if .Values.admin.enabled }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ .Release.Name }}-admin
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  maxUnavailable: {{ .Values.pdb.maxUnavailable }}
  selector:
    matchLabels:
      {{- include "pc.selector" (dict "root" . "component" "admin") | nindent 6 }}
{{- end }}

Three routes. pocketcoffee-www sends the shop host to the Varnish Service (or, with Varnish off, to the web Service). pocketcoffee-admin matches the path prefix of the Admin and sends it straight to the Admin Service, or to the web Service when admin.enabled is false; the longer prefix wins over /, so the Admin never goes through Varnish, which must not cache anything of it. pocketcoffee-apex redirects the apex name to www with a 301, so the shop has one canonical host. Magento builds its URLs from the base URL of env.php and trusts X-Forwarded-Proto through the offloader_header setting, so the route forwards the host unchanged. Gateway API support on EKS needs the AWS Load Balancer Controller; see Gateway API.

The HorizontalPodAutoscaler scales the web Deployment on CPU against the requests, with a five-minute scale-down window; better signals (FPM saturation, request rate) are on the operations page. The PodDisruptionBudgets allow one pod at a time: Auto Mode replaces nodes at least every 21 days, and a budget that is too strict blocks those drains. While install.enabled is set the HPA is not rendered and the Deployments have no replicas.

Queue consumers

{{- range .Values.consumers }}
{{- $slug := include "pc.consumerSlug" .name }}
{{- $component := printf "consumer-%s" $slug }}
{{- $name := printf "%s-consumer-%s" $.Release.Name $slug }}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ $name }}
  labels:
    {{- include "pc.labels" $ | nindent 4 }}
    app.kubernetes.io/component: {{ $component }}
spec:
  {{- if $.Values.install.enabled }}
  replicas: 0
  {{- else if not $.Values.keda.enabled }}
  replicas: {{ .minReplicas }}
  {{- end }}
  revisionHistoryLimit: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0
  selector:
    matchLabels:
      {{- include "pc.selector" (dict "root" $ "component" $component) | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "pc.selector" (dict "root" $ "component" $component) | nindent 8 }}
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") $ | sha256sum }}
    spec:
      serviceAccountName: {{ include "pc.serviceAccountName" $ }}
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: {{ $.Values.consumer.terminationGracePeriodSeconds }}
      securityContext:
        {{- include "pc.podSecurity" $ | nindent 8 }}
      containers:
        - name: consumer
          image: {{ include "pc.image" $ }}
          imagePullPolicy: {{ $.Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" $ | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          # exec: the consumer is PID 1 and receives SIGTERM. It exits after maxMessages and the kubelet restarts it.
          command: ["sh", "-c"]
          args:
            - exec bin/magento queue:consumers:start {{ .name }} --max-messages={{ .maxMessages }}
          workingDir: /var/www/html
          envFrom:
            {{- include "pc.envFrom" $ | nindent 12 }}
          env:
            # Consumers can trigger partial reindexing, which truncates and creates index tables.
            {{- include "pc.dbEnv" (dict "root" $ "user" $.Values.database.migrateUser "key" "MAGENTO_MIGRATE_DB_PASSWORD") | nindent 12 }}
          resources:
            {{- toYaml $.Values.consumer.resources | nindent 12 }}
          volumeMounts:
            {{- include "pc.phpMounts" $ | nindent 12 }}
      volumes:
        {{- include "pc.phpVolumes" $ | nindent 8 }}
{{- if and $.Values.keda.enabled (not $.Values.install.enabled) }}
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: {{ $name }}
  labels:
    {{- include "pc.labels" $ | nindent 4 }}
spec:
  scaleTargetRef:
    name: {{ $name }}
  minReplicaCount: {{ .minReplicas }}
  maxReplicaCount: {{ .maxReplicas }}
  pollingInterval: {{ $.Values.keda.pollingIntervalSeconds }}
  cooldownPeriod: {{ $.Values.keda.cooldownPeriodSeconds }}
  triggers:
    - type: rabbitmq
      metadata:
        protocol: http
        host: {{ required "set keda.managementUrl" $.Values.keda.managementUrl | quote }}
        vhostName: {{ $.Values.amqp.vhost | quote }}
        queueName: {{ .queue | quote }}
        mode: QueueLength
        value: {{ .messagesPerReplica | default 100 | quote }}
        # The management API is called with the broker user of env.php, read from the target's environment.
        usernameFromEnv: MAGENTO_AMQP_USER
        passwordFromEnv: MAGENTO_AMQP_PASSWORD
{{- end }}
---
{{- end }}

One Deployment per entry of consumers, named pocketcoffee-consumer- plus the consumer name with dots and underscores turned into dashes (async.operations.all becomes async-operations-all), running bin/magento queue:consumers:start <name> --max-messages=1000. With exec the consumer is PID 1 and receives the termination signal; it exits after the given number of messages (a defence against leaks in long-running PHP) and the kubelet restarts the container, with a short back-off under constant load; with queue/consumers_wait_for_messages at 1 (set in env.php) an idle consumer waits for messages instead of exiting. The ScaledObject of the same name scales the Deployment on the length of the queue through the broker’s management API (protocol: http), messagesPerReplica messages per replica, between minReplicas and maxReplicas; a minimum of 0 is allowed for a queue that is usually empty (the image resize consumer). KEDA reads the user name and password from the environment of the Deployment’s container (usernameFromEnv, passwordFromEnv), so no second secret exists; the broker user of env.php must therefore be allowed to use the management API. The cron page shows the stricter variant, a monitoring-only broker user and a TriggerAuthentication. With keda.enabled=false the Deployments run minReplicas pods. Why consumers are not spawned by cron, which consumers a shop needs and the other trade-offs are on Cloud: Cron, Queue Consumers and Indexers; the three consumers in the values are examples (an asynchronous API consumer, a mass attribute update and the image resize), and the names a shop has are listed by bin/magento queue:consumers:list.

Cron

{{- if .Values.cron.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
  name: {{ .Release.Name }}-cron
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  schedule: {{ .Values.cron.schedule | quote }}
  concurrencyPolicy: Forbid
  suspend: {{ .Values.install.enabled }}
  startingDeadlineSeconds: 120
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 0
      activeDeadlineSeconds: {{ .Values.cron.activeDeadlineSeconds }}
      ttlSecondsAfterFinished: 3600
      template:
        metadata:
          labels:
            {{- include "pc.selector" (dict "root" . "component" "cron") | nindent 12 }}
        spec:
          restartPolicy: Never
          serviceAccountName: {{ include "pc.serviceAccountName" . }}
          automountServiceAccountToken: false
          securityContext:
            {{- include "pc.podSecurity" . | nindent 12 }}
          containers:
            - name: cron
              image: {{ include "pc.image" . }}
              imagePullPolicy: {{ .Values.image.pullPolicy }}
              securityContext:
                {{- include "pc.containerSecurity" . | nindent 16 }}
                runAsUser: 33
                runAsGroup: 33
              command: ["sh", "-c"]
              args:
                - |
                  set -eu
                  cd /var/www/html
                  {{- if .Values.cron.groups }}
                  {{- range .Values.cron.groups }}
                  bin/magento cron:run --group={{ . }} --bootstrap=standaloneProcessStarted=1
                  {{- end }}
                  {{- else }}
                  bin/magento cron:run
                  {{- end }}
              envFrom:
                {{- include "pc.envFrom" . | nindent 16 }}
              env:
                # The index cron group reindexes, which truncates and creates index tables.
                {{- include "pc.dbEnv" (dict "root" . "user" .Values.database.migrateUser "key" "MAGENTO_MIGRATE_DB_PASSWORD") | nindent 16 }}
              resources:
                {{- toYaml .Values.cron.resources | nindent 16 }}
              volumeMounts:
                {{- include "pc.phpMounts" . | nindent 16 }}
          volumes:
            {{- include "pc.phpVolumes" . | nindent 12 }}
{{- end }}

One CronJob pocketcoffee-cron runs every minute with concurrencyPolicy: Forbid, so there is exactly one scheduler however many web pods run; the web pods never run cron. The command is bin/magento cron:run --group=<group> --bootstrap=standaloneProcessStarted=1 once per entry of cron.groups (default and index by default), one after the other, in the foreground. The flag matters. In 2.4.9 ProcessCronQueueObserver starts every group whose use_separate_process is 1 (the index and consumers groups; default is 0) as a child process through a shell whose command renderer (Magento\Cron\Shell\CommandRendererBackground) appends >> var/log/magento.cron.<group>.log 2>&1 &, and then returns. In a container that ends the pod while the group still runs, and the log goes to a file in an emptyDir that disappears with it. The child is started with exactly this --bootstrap flag, which makes the process run the group itself; the CronJob uses the flag directly, so the work finishes before the pod does and its output is in kubectl logs. The group consumers is left out on purpose, and its job consumers_runner is disabled in env.php. Setting cron.groups to an empty list runs one plain cron:run, which is only right for a store whose groups are all inline; which groups a shop needs, the lock behaviour and the schedule table are on Cloud: Cron, Queue Consumers and Indexers.

The migrate and install Jobs

{{- if .Values.migrate.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-migrate
  labels:
    {{- include "pc.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 0
  activeDeadlineSeconds: {{ .Values.migrate.activeDeadlineSeconds }}
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        {{- include "pc.selector" (dict "root" . "component" "migrate") | nindent 8 }}
    spec:
      restartPolicy: Never
      serviceAccountName: {{ include "pc.serviceAccountName" . }}
      automountServiceAccountToken: false
      securityContext:
        {{- include "pc.podSecurity" . | nindent 8 }}
      # setup:upgrade wants a writable app/etc; the image's config.php is copied into a throwaway directory.
      initContainers:
        - name: etc
          image: {{ include "pc.image" . }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          command: ["sh", "-c", "cp -a /var/www/html/app/etc/. /etc-work/"]
          volumeMounts:
            - { name: etc-work, mountPath: /etc-work }
      containers:
        - name: upgrade
          image: {{ include "pc.image" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          workingDir: /var/www/html
          command: ["sh", "-c"]
          args:
            - |
              set -u
              # 0 = schema and data are current, 2 = upgrade required, anything else = failure.
              rc=0
              bin/magento setup:db:status --no-interaction || rc=$?
              if [ "$rc" -eq 0 ]; then
                echo "Database is up to date; nothing to do."
              elif [ "$rc" -eq 2 ]; then
                exec bin/magento setup:upgrade --keep-generated --no-interaction
              else
                echo "setup:db:status failed with exit code $rc" >&2
                exit "$rc"
              fi
          envFrom:
            {{- include "pc.envFrom" . | nindent 12 }}
          env:
            {{- include "pc.dbEnv" (dict "root" . "user" .Values.database.migrateUser "key" "MAGENTO_MIGRATE_DB_PASSWORD") | nindent 12 }}
          resources:
            requests: { cpu: 500m, memory: 1Gi }
            limits: { memory: 2Gi }
          volumeMounts:
            {{- include "pc.phpMounts" . | nindent 12 }}
            - { name: etc-work, mountPath: /var/www/html/app/etc }
      volumes:
        {{- include "pc.phpVolumes" . | nindent 8 }}
        - { name: etc-work, emptyDir: { sizeLimit: 64Mi } }
{{- end }}

The migrate Job is a pre-upgrade hook: it runs with the new image before the new pods roll out, as mg_migrate. It asks bin/magento setup:db:status first, and setup:db:status ends with 0 when schema and data are current, with 2 when an upgrade is required and with 1 on a failure (EXIT_CODE_UPGRADE_REQUIRED in DbStatusCommand); only on 2 does it run bin/magento setup:upgrade --keep-generated, so an unchanged release costs a few seconds and a database that cannot be read fails the release. --keep-generated keeps the compiled code that the image carries (without it setup:upgrade deletes generated/code). The upgrade wants a writable app/etc and var: its pre-flight check (getMissingWritableDirectoriesForDbUpgrade in the framework’s FilePermissions) fails when app/etc is not writable, so the Job copies the image’s app/etc into an emptyDir with an init container, mounts it at app/etc and mounts the read-only env.php over it. setup:upgrade then reads env.php and writes nothing to it (the cleanup of old init statements writes only when it changes something) and may rewrite the throwaway config.php, which is discarded. A failing Job fails the upgrade and leaves the old pods running. A caveat of Helm hooks: a pre-upgrade hook runs before the other objects of the release are applied, so a change to a ConfigMap or to the Secret made in the same release is not yet seen by the Job. The sequence around it (maintenance mode, the compatibility of old pods with the new schema, rollback) is on Cloud: Releases and Zero-Downtime Deployments, and the declarative schema it applies on Declarative Schema and Data and Schema Patches.

{{- if .Values.install.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-install
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 2400
  template:
    metadata:
      labels:
        {{- include "pc.selector" (dict "root" . "component" "install") | nindent 8 }}
    spec:
      restartPolicy: Never
      serviceAccountName: {{ include "pc.serviceAccountName" . }}
      automountServiceAccountToken: false
      securityContext:
        {{- include "pc.podSecurity" . | nindent 8 }}
      # The installer writes app/etc/env.php: it gets a throwaway copy of app/etc, discarded with the pod.
      initContainers:
        - name: etc
          image: {{ include "pc.image" . }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          command: ["sh", "-c", "cp -a /var/www/html/app/etc/. /etc-work/"]
          volumeMounts:
            - { name: etc-work, mountPath: /etc-work }
      containers:
        - name: install
          image: {{ include "pc.image" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          workingDir: /var/www/html
          command: ["sh", "-c"]
          args:
            - |
              set -eu
              # Refuse to install over an existing shop: setup:install would try to reuse its tables.
              rc=0
              php -r '
                $pdo = new PDO(
                  "mysql:host=" . getenv("MAGENTO_DB_HOST") . ";port=" . getenv("MAGENTO_DB_PORT") . ";dbname=" . getenv("MAGENTO_DB_NAME"),
                  getenv("MAGENTO_DB_USER"), getenv("MAGENTO_DB_PASSWORD"));
                exit($pdo->query("SHOW TABLES LIKE \"core_config_data\"")->rowCount() > 0 ? 3 : 0);
              ' || rc=$?
              if [ "$rc" -eq 3 ]; then
                echo "core_config_data exists: this database already holds a shop" >&2
                exit 3
              elif [ "$rc" -ne 0 ]; then
                echo "could not query the database (exit code $rc)" >&2
                exit "$rc"
              fi
              bin/magento setup:install --no-interaction \
                --base-url="$MAGENTO_BASE_URL" \
                --db-host="$MAGENTO_DB_HOST:$MAGENTO_DB_PORT" --db-name="$MAGENTO_DB_NAME" \
                --db-user="$MAGENTO_DB_USER" --db-password="$MAGENTO_DB_PASSWORD" \
                --key="$MAGENTO_CRYPT_KEY" --backend-frontname="$MAGENTO_BACKEND_FRONTNAME" \
                --search-engine=opensearch --opensearch-host="https://$MAGENTO_OPENSEARCH_HOST" \
                --opensearch-port="$MAGENTO_OPENSEARCH_PORT" --opensearch-enable-auth=1 \
                --opensearch-username="$MAGENTO_OPENSEARCH_USER" --opensearch-password="$MAGENTO_OPENSEARCH_PASSWORD" \
                --opensearch-index-prefix="$MAGENTO_OPENSEARCH_PREFIX" --opensearch-timeout=15 \
                --admin-firstname={{ .Values.install.adminFirstname | quote }} --admin-lastname={{ .Values.install.adminLastname | quote }} \
                --admin-email={{ .Values.install.adminEmail | quote }} --admin-user={{ .Values.install.adminUser | quote }} \
                --admin-password="$ADMIN_PASSWORD" \
                --language={{ .Values.install.language }} --currency={{ .Values.install.currency }} \
                --timezone={{ .Values.install.timezone }} --use-rewrites=1
          envFrom:
            {{- include "pc.envFrom" . | nindent 12 }}
          env:
            {{- include "pc.dbEnv" (dict "root" . "user" .Values.database.migrateUser "key" "MAGENTO_MIGRATE_DB_PASSWORD") | nindent 12 }}
            - name: ADMIN_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ .Release.Name }}-install
                  key: ADMIN_PASSWORD
          resources:
            requests: { cpu: "1", memory: 2Gi }
            limits: { memory: 3Gi }
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
            - { name: var, mountPath: /var/www/html/var }
            - { name: media, mountPath: /var/www/html/pub/media }
            - { name: etc-work, mountPath: /var/www/html/app/etc }
            - { name: php, mountPath: /usr/local/etc/php/conf.d/zz-pocketcoffee.ini, subPath: php.ini, readOnly: true }
      volumes:
        - { name: tmp, emptyDir: { sizeLimit: 256Mi } }
        - { name: var, emptyDir: { sizeLimit: 2Gi } }
        - { name: media, emptyDir: { sizeLimit: 512Mi } }
        - { name: etc-work, emptyDir: { sizeLimit: 64Mi } }
        - name: php
          configMap:
            name: {{ .Release.Name }}-php
{{- end }}

The install Job runs bin/magento setup:install once, in an empty database. It deliberately differs from the web pods:

  • It writes app/etc/env.php into a throwaway directory. The installer’s pre-flight check requires app/etc, var and pub/media to be writable (and generated and pub/static only outside production mode, which the image’s mode makes unnecessary). The init container copies the image’s app/etc, with its config.php, into an emptyDir; the installer writes its own env.php there with its own defaults (file cache and file sessions, which is why the Job needs neither Valkey nor the broker), and the directory disappears with the pod. That is intended: the env.php that counts is the one built from the environment.

  • It uses the key from the secret. --key takes the value of MAGENTO_CRYPT_KEY; the installer writes it to its throwaway file and everything it encrypts during the install uses it, which is what makes the shop readable by the pods that start afterwards. The key appears in the command line of the Job’s process, which anyone with exec access to the pod can read; treat the namespace accordingly.

  • It refuses to run over an existing shop. A PDO query checks for the table core_config_data before anything else and ends with exit code 3 if it exists. The installer’s --cleanup-database option is deliberately not used, because it drops tables.

  • It creates the shop’s first administrator from the install secret. The password is a Secrets Manager value, passed in a variable and then on the command line, where it shows in the process list of the Job pod: treat it as a first-login password, change it from the Admin and delete the secret. The Admin path is set explicitly with --backend-frontname to the value the WAF rule and the route use, instead of the random one the installer would choose.

  • It reaches OpenSearch and creates the first index. The installer validates the search connection, stores the settings, and reindexes at the end; on an empty catalogue that is short. The settings it stores in the database are overridden by the locked system node of env.php afterwards.

  • It is not a hook. A post-install hook would run after the pods are ready, and they would serve a half-installed shop. So the chart deploys the Job as an ordinary object and, while install.enabled is true, scales the web, the Admin and the consumer Deployments and the Varnish StatefulSet to zero and suspends the cron. The deployment script runs the chart twice: the Job first, the pods second.

  • --base-url and the language options are deprecated in 2.4.9. The installer’s help marks them so; they still work, and the locked system node of env.php supersedes the stored base URL.

Reindexing on demand

{{- if .Values.reindex.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-reindex{{ if .Values.reindex.run }}-{{ .Values.reindex.run }}{{ end }}
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 14400
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels:
        {{- include "pc.selector" (dict "root" . "component" "reindex") | nindent 8 }}
    spec:
      restartPolicy: Never
      serviceAccountName: {{ include "pc.serviceAccountName" . }}
      automountServiceAccountToken: false
      securityContext:
        {{- include "pc.podSecurity" . | nindent 8 }}
      containers:
        - name: reindex
          image: {{ include "pc.image" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          workingDir: /var/www/html
          command: ["sh", "-c"]
          args:
            - exec bin/magento indexer:reindex {{ join " " .Values.reindex.indexers }}
          envFrom:
            {{- include "pc.envFrom" . | nindent 12 }}
          env:
            # Reindexing creates and swaps index tables, which the runtime user may not do.
            {{- include "pc.dbEnv" (dict "root" . "user" .Values.database.migrateUser "key" "MAGENTO_MIGRATE_DB_PASSWORD") | nindent 12 }}
          resources:
            requests: { cpu: "1", memory: 2Gi }
            limits: { memory: 4Gi }
          volumeMounts:
            {{- include "pc.phpMounts" . | nindent 12 }}
      volumes:
        {{- include "pc.phpVolumes" . | nindent 8 }}
{{- end }}

pocketcoffee-reindex (with -<run> appended when reindex.run is set, so that a second run does not collide with a finished Job) is rendered only with reindex.enabled=true and runs bin/magento indexer:reindex (all indexers, or the ones in reindex.indexers) as mg_migrate, because a full reindex creates and swaps index tables. It is for a one-off full reindex after an import or a migration; the steady-state indexing is "Update by Schedule" through cron, described on the cron page, not a Job.

First install, or an existing database

There are two ways to create the database content, and the chart supports both with the same values:

First install with the Job Import of an existing shop

When

A new shop, or a staging environment created from scratch

A shop that runs on a single server today

Database

Empty database pocketcoffee, users mg_app and mg_migrate created

A dump imported into RDS, the same users created

Secrets

The key generated once outside the cluster, the rest by the seeding script

The old crypt/key copied into the secret; the other values generated

Files

Written to the bucket by the shop afterwards

The old pub/media uploaded with remote-storage:sync, or copied to the volume

install.enabled

true for the first release only

Never

Base URLs

From env.php, locked

From env.php, locked; remove the old values from core_config_data if they conflict

An import is a different procedure with its own risks (the old key must be kept or encrypted values are lost); it is on Cloud: Migrating from a Single Server. The deployment script below is the first install; for an import, skip the first helm upgrade and run the second.

#!/usr/bin/env bash
# First deployment of the chart to pocketcoffee-prod: install Job first, then the web pods.
set -euo pipefail

sha="${1:?usage: deploy.sh <git-sha>}"
region="eu-west-1"
tf() { tofu -chdir=infra output -raw "$1"; }

aws eks update-kubeconfig --name pocketcoffee-prod --region "$region"

common=(
  --namespace pocketcoffee
  --set image.tag="$sha"
  --set database.host="$(tf db_endpoint)"
  --set opensearch.host="$(tf opensearch_endpoint)"
  --set valkey.cacheHost="$(tf valkey_cache_endpoint)"
  --set valkey.sessionHost="$(tf valkey_session_endpoint)"
  --set amqp.host="$(tf amqp_host)"
  --set media.bucket="$(tf media_bucket)"
)

# 1. Install: the web pods and consumers stay at zero replicas while the install Job creates the shop.
helm upgrade --install pocketcoffee ./pocketcoffee-magento "${common[@]}" \
  --set install.enabled=true --set migrate.enabled=false \
  --wait --wait-for-jobs --timeout 40m

# 2. Serve: the Job is removed, the pods start against the finished database.
helm upgrade pocketcoffee ./pocketcoffee-magento "${common[@]}" \
  --set migrate.enabled=false --wait --timeout 15m

kubectl -n pocketcoffee get pods,hpa,pdb,cronjob,scaledobject

The script passed shellcheck and bash -n. --wait-for-jobs makes Helm wait until the install Job completes (the timeout covers the schema creation, the indexing and a slow OpenSearch domain); the second command removes the Job, creates the HPA, scales the Deployments up and starts the pods. migrate.enabled=false is set on both runs because the installer already created the current schema and modules; later releases run with the default (migrate on). Run tofu -chdir=infra output first, as the script does, so the chart receives the endpoints. After the first release, delete the install secret from Secrets Manager.

Verifying the deployment

kubectl -n pocketcoffee get pods -o wide
kubectl -n pocketcoffee get httproute,scaledobject
curl -fsS -o /dev/null -w '%{http_code}\n' https://www.pocketcoffee.example/
curl -fsS https://www.pocketcoffee.example/health_check.php -o /dev/null -w '%{http_code}\n'
curl -fsSI https://www.pocketcoffee.example/admin_pocketcoffee/ | head -n 1   # 403 from outside the allow-list
kubectl -n pocketcoffee exec deploy/pocketcoffee-web -c php-fpm -- bin/magento config:show web/secure/base_url
kubectl -n pocketcoffee exec deploy/pocketcoffee-web -c php-fpm -- bin/magento cache:status
kubectl -n pocketcoffee exec deploy/pocketcoffee-web -c php-fpm -- bin/magento queue:consumers:list

Check that every web pod shows 2/2 containers ready and that they run in more than one zone; that the shop answers with the www host and its links are HTTPS; that the Admin answers 403 from an address outside admin_cidrs and its login page from inside; that config:show returns the locked base URL; that cache:status shows every type enabled (a missing type is the sign of a short cache_types list); and that a second request to a product page carries the Varnish headers of the page cache page. The state of a consumer appears in the logs of its pods and in the queue length of the broker’s console.

Common problems

  • required: set image.tag to the git SHA, set database.host and similar. The chart refuses to render without them: pass the values from the script.

  • Every pod fails with Configuration error and a line missing environment variable in the log. The ExternalSecret has not produced the Secret (check kubectl describe externalsecret and the Pod Identity association of the operator) or a key is missing from the secret JSON.

  • Pods are unready and health_check.php answers 500. The check failed on the database or on a cache frontend of env.php: read the log of the PHP container (MySQL connection failed and Cache storage is not accessible are the two messages the script logs), and check the security groups and the AUTH tokens.

  • Every request is a redirect loop or links point at http. The load balancer does not send X-Forwarded-Proto or the offloader_header setting is missing from the system node; the value is X-Forwarded-Proto.

  • The Admin shows the store configuration fields greyed out. That is the locked system node working as intended; to change a value, change the chart.

  • "Cache storage is not accessible" on the session group only. The AUTH token of the session group was put into the cache setting or the other way round: the two secret keys are different.

  • Search returns nothing after the first release. The installer indexed an empty catalogue; run the reindex Job after importing products, and check that opensearch_server_hostname carries https://.

  • The migrate Job fails with "Path app/etc is not writable". The init container did not run or the etc-work volume is missing from a customised copy of the template.

  • setup:upgrade rewrote config.php in the image. It did not, it rewrote the throwaway copy; if the module list differs from the baked file, rebuild the image with app:config:dump and commit the file.

  • A consumer does not start or its queue only grows. Look at the Deployment’s log first; then at the broker user’s rights, at consumers_wait_for_messages and at the ScaledObject (kubectl describe scaledobject).

  • The install Job fails at the first write. app/etc, var or pub/media is not writable by UID 33: the pod’s fsGroup and the emptyDir volumes decide.

  • Pods cannot mount EFS. Check the mount target security group, that a mount target exists in the pod’s zone and that the PV names the file system and the access point of the outputs.

Version notes and what was not verified

  • Validated locally: OpenTofu 1.12.6 init -backend=false, fmt -check and validate in both workspaces and with media_mode = "efs" (providers aws 6.71.0 and random 3.9.1); Helm 4.3.1 lint and template with kubeconform 0.8.0 -strict -ignore-missing-schemas for Kubernetes 1.36.0 in the four configurations of the chart section; env.php with php -l and a run in PHP 8.5.12 with test values; the scripts and every command rendered into the CronJob, the consumers and the Jobs with shellcheck and sh -n; the Varnish container with a read-only root file system as UID 1000; the rendered nginx file together with the 2.4.9 nginx.conf.sample (nginx -t, and a request test of /healthz, /setup and /update); xmllint --noout on both figures.

  • Not verified: anything against a live account or cluster (no plan, apply or install was run): the pods' security group as the source of every data-tier rule (check the node security group of a running pod and switch the rules if it differs), the IAM and KMS interplay (the OpenSearch, Amazon MQ and ElastiCache keys), the Gateway and its web ACL attachment, the ExternalSecret, HTTPRoute and ScaledObject fields (skipped by kubeconform), the installer against RDS, OpenSearch and the real image (the Job’s script was read against the 2.4.9 source and shell-checked, not run), setup:upgrade with a read-only env.php mount, KEDA’s access to the Amazon MQ management API, the session handler against ElastiCache with TLS, the Pod Identity credentials in the SDK version of your composer.lock, the nginx file with the real PHP locations (no PHP ran in the nginx test), the rotation of crypt/key described above, and what the platform costs (this page lists no prices).

  • Source reading, not execution: Magento behaviour described here (health_check.php, Installer, FilePermissions, UpgradeCommand, DbStatusCommand, CacheIdCalculator, State, AwsS3Factory, the AMQP connection factory, the OpenSearch client, Encryptor and CryptKeyGenerator) was read in the 2.4.9 source (github.com/magento/magento2, tag 2.4.9); Mage-OS 3.6.0 follows the same code base but its differences were not diffed for these files. The Credis behaviour (tls://) was read in its repository.

  • Adobe’s version table for AWS services (2.4.9: OpenSearch 3.1 or latest, AWS MQ 3.13 or latest, ElastiCache Valkey 8, Aurora MySQL 3.12, nginx 1.30, Varnish 8) was read on 2026-10-29 from the documentation repository; ElastiCache’s engine list names Valkey 9.0 and 9.1 on the same date. Where the two differ this page follows the engine versions Magento 2.4.9 tests and says so; confirm the versions you deploy against both documents.

  • Module versions are exact pins from the OpenTofu registry as of 2026-10-29 and their inputs change between major versions. EKS standard support for 1.36 runs until August 2027 and for 1.37 until December 2027 (see EKS Clusters).

  • Aurora, TLS to the database and RDS Proxy are on the database page; Google Cloud and Azure equivalents on Cloud: Google Cloud, Azure and Alternatives. Observability, backups and the alarms that the sizing above relies on are on the operations page.