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
One root module in infra/ creates one environment; the workspace (staging or production) chooses which. The files are split by service:
| File | Contents |
|---|---|
|
Constraints, the S3 backend, two provider blocks (the second is |
|
VPC with public, private, database and ElastiCache subnets in three zones; one customer-managed key |
|
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 for MySQL 8.4 with the parameter group |
|
Amazon OpenSearch Service domain, VPC access, fine-grained access control with an internal master user |
|
Two Valkey replication groups, |
|
Amazon MQ for RabbitMQ 4.3 broker, a cluster in production |
|
The S3 media bucket, the IAM role of the Magento pods bound to the service account with Pod Identity; the EFS alternative |
|
Two image repositories and the GitHub OIDC deployment role; the Secrets Manager containers |
|
The regional site certificate and the |
|
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 availableand ElastiCacheValkey 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 whatallkeys-lruwould 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, andenv.phphas nopage_cachefrontend. -
Amazon MQ needs the Graviton family for 4.x. RabbitMQ 4.3 and 4.2 run only on the
mq.m7ginstance types in Amazon’s documentation, so the single instance of staging usesmq.m7g.largeas 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_nameand 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:
-
utf8mb4andutf8mb4_general_ciare the character set and collation Magento uses; in 2.4.9 the adapter chooses its ownSET NAMESstatement whenenv.phphas noinitStatements(DB/Adapter/Pdo/Mysql.phphas a default derived from the database version), which is why theenv.phpbelow omits the key. The installer’s oldSET NAMES utf8is removed bysetup:upgradefrom any file that still has it, and that removal is the one write toenv.phpthe 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_packetis raised to 128 MiB for large imports and for the serialized configuration that some modules store. -
There is no
sql_modeentry on purpose: the page on the database says what Magento sets per connection; change nothing here without testingsetup: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:portfrom 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_enabledwith 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.5is the current version of the service and Adobe’s 2.4.9 requirement is "OpenSearch 3"; Adobe’s AWS row says3.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
}
}
Source: AWS provider: aws_mq_broker
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
}
}
Source: AWS provider: aws_wafv2_web_acl
-
ECR. Two repositories,
pocketcoffee/magento(two tags per release, see below) andpocketcoffee/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-deploypushes images and may calleks:DescribeCluster; with the access entry ofeks.tfit 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 ingithub_oidc_provider_arn. -
Secrets. Two containers:
pocketcoffee/prod/magentowith the seven keys of the table below, andpocketcoffee/prod/magento-installwith 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 inus-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_namefor 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 containsSizeRestrictions_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 |
|---|---|---|
|
|
Web and Admin pods, as |
|
|
Cron, consumers and the install, migrate and reindex workloads, as |
|
|
|
|
|
The |
|
|
The cache backend |
|
|
The session handler |
|
|
The |
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/keyentry of its currentapp/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:CryptKeyGeneratorinMagento\Setup\Modelreturns the prefixbase64followed by the base64 text of 32 random bytes (the length of a libsodium key), andConfigGeneratoruses 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\Encryptorsplits the setting on whitespace, treats the last entry as the newest and decrypts older values with the earlier ones, which is howbin/magento encryption:key:changerotates the key. That command writes the new key toenv.php, which is read-only here, so a rotation is a deliberate procedure: run the command in a throwaway pod with a writable copy ofapp/etc(the pattern of the install Job below), copy the resultingcrypt/keyvalue 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,installanddownloadable_domainsare the nodes of a normal installation;directories/document_root_is_pubmakespubthe document root. -
The GraphQL salt must be fixed.
Magento\GraphQlCache\Model\CacheId\CacheIdCalculatorreadscache/graphql/id_saltand, when it is missing, generates a salt and saves it toenv.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_typesis a list of what is enabled, and a missing entry means disabled.Magento\Framework\App\Cache\Statereads the node and treats an unknown type as off, andcache:enableand the installer persist the node by writingenv.php, which no pod can do. The list in the file is the usual set; produce yours withbin/magento cache:statuson a finished installation and add the types of your modules (Pocket Coffee’sPocketCoffee_RoastAdminadds one), because a type missing here is a cache that is silently off. -
The search settings live in the
systemnode. 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 tohttpwhen there is none (the file addshttps://); the port is 443; the user and password make Magento embeduser: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 identifiervalkeyand the session handler isrediswith a Valkey server, the combination that the installation page found to work on Mage-OS 3.6.0 (thevalkeysession option set was ignored by that installer); test the session handler of your edition by logging in and looking forsess_keys in the session group.max_concurrencyof 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_hostslists every Varnish pod by name. Magento sends aPURGErequest 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_applicationis 2). -
Queues.
default_connectionisamqp,sslis on with certificate and host name verification (the AMQP factory of 2.4.9 readssslandssl_optionssuch ascafileandverify_peer), andcron_consumers_runner/cron_runis off, because the consumers are Deployments and not processes that cron spawns. -
Media. With
MAGENTO_S3_BUCKETset, theremote_storagenode selects the S3 driver with a bucket and a region and no credentials; without it, media stays on the file system (modeefs). The Admin settingsystem/media_storage_configuration/media_databaseis locked to 0 as Adobe’s remote storage page requires.MAGENTO_MEDIA_BASE_URLpoints the media base URLs at the CloudFront host. -
install/dateis 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
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
Source: Helm Docs: Values files
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 -}}
Source: Helm Docs: Named templates
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
Source: PHP Manual: FPM configuration
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 |
|---|---|
|
|
|
The RDS endpoint, 3306, |
|
The domain endpoint (without scheme), 443, |
|
The two primary endpoints and 6379 |
|
The broker endpoint, 5671, |
|
Comma-separated pod DNS names of the Varnish StatefulSet, port 6081 |
|
Only with |
|
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
-nginximage and passes.phpto PHP-FPM on127.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: 0makes a rollout add a pod before it removes one. -
Probes:
health_check.phpfor readiness, a static file for liveness. Magento’spub/health_check.php(read in the 2.4.9 source) boots the application, opens a connection for every entry ofdb/connectionand creates and tests every cache frontend ofenv.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/healthzand 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/healthzand 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,varandpub/media(modes3) are per-podemptyDirvolumes;generated/(the compiled dependency injection) andpub/static(the deployed static content) are baked into the image and never written in production mode, andapp/etcis not writable at all:env.phpis a read-only file mount andconfig.phpis 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: 33makes theemptyDirvolumes writable forwww-data. -
preStopsleep andterminationGracePeriodSeconds. 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 }}
Source: KEDA: RabbitMQ Queue scaler
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 }}
Source: Helm Docs: Chart hooks
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.phpinto a throwaway directory. The installer’s pre-flight check requiresapp/etc,varandpub/mediato be writable (andgeneratedandpub/staticonly outside production mode, which the image’s mode makes unnecessary). The init container copies the image’sapp/etc, with itsconfig.php, into anemptyDir; the installer writes its ownenv.phpthere 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: theenv.phpthat counts is the one built from the environment. -
It uses the key from the secret.
--keytakes the value ofMAGENTO_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_databefore anything else and ends with exit code 3 if it exists. The installer’s--cleanup-databaseoption 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-frontnameto 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
systemnode ofenv.phpafterwards. -
It is not a hook. A
post-installhook 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, whileinstall.enabledis 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-urland the language options are deprecated in 2.4.9. The installer’s help marks them so; they still work, and the lockedsystemnode ofenv.phpsupersedes 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 |
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 |
Files |
Written to the bucket by the shop afterwards |
The old |
|
|
Never |
Base URLs |
From |
From |
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
Source: Helm Docs: helm upgrade
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.hostand similar. The chart refuses to render without them: pass the values from the script. -
Every pod fails with
Configuration errorand a linemissing environment variablein the log. The ExternalSecret has not produced the Secret (checkkubectl describe externalsecretand the Pod Identity association of the operator) or a key is missing from the secret JSON. -
Pods are unready and
health_check.phpanswers 500. The check failed on the database or on a cache frontend ofenv.php: read the log of the PHP container (MySQL connection failedandCache storage is not accessibleare 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 sendX-Forwarded-Protoor theoffloader_headersetting is missing from thesystemnode; the value isX-Forwarded-Proto. -
The Admin shows the store configuration fields greyed out. That is the locked
systemnode 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_hostnamecarrieshttps://. -
The migrate Job fails with "Path app/etc is not writable". The init container did not run or the
etc-workvolume is missing from a customised copy of the template. -
setup:upgraderewroteconfig.phpin the image. It did not, it rewrote the throwaway copy; if the module list differs from the baked file, rebuild the image withapp:config:dumpand 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_messagesand at the ScaledObject (kubectl describe scaledobject). -
The install Job fails at the first write.
app/etc,varorpub/mediais not writable by UID 33: the pod’sfsGroupand theemptyDirvolumes 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 -checkandvalidatein both workspaces and withmedia_mode = "efs"(providers aws 6.71.0 and random 3.9.1); Helm 4.3.1lintandtemplatewith kubeconform 0.8.0-strict -ignore-missing-schemasfor Kubernetes 1.36.0 in the four configurations of the chart section;env.phpwithphp -land a run in PHP 8.5.12 with test values; the scripts and every command rendered into the CronJob, the consumers and the Jobs withshellcheckandsh -n; the Varnish container with a read-only root file system as UID 1000; the rendered nginx file together with the 2.4.9nginx.conf.sample(nginx -t, and a request test of/healthz,/setupand/update);xmllint --noouton 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:upgradewith a read-onlyenv.phpmount, 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 yourcomposer.lock, the nginx file with the real PHP locations (no PHP ran in the nginx test), the rotation ofcrypt/keydescribed 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,EncryptorandCryptKeyGenerator) was read in the 2.4.9 source (github.com/magento/magento2, tag2.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.
References
-
Amazon RDS User Guide: Password management with Secrets Manager
-
Amazon OpenSearch Service Developer Guide: Fine-grained access control
-
Amazon ElastiCache User Guide: Engine versions and upgrading
-
Kubernetes Documentation: Liveness, readiness and startup probes
-
Adobe Commerce docs: Configure AWS S3 bucket for remote storage
-
Adobe Commerce docs: Install the application from the command line