Media: S3 Offload, EFS and CloudFront

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

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

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, the security release of 2026-10-28, is the version to run), PHP 8.4, WP-CLI 2.12.0, Kubernetes 1.36 and 1.37, the Amazon EFS CSI driver 3.5.3, OpenTofu 1.12 with the AWS provider 6.x and WP Offload Media Lite 3.4.4 (listed as tested up to WordPress 7.1.4 and requiring PHP 8.1), and checked on 2026-10-29. Listings were validated with tofu validate -backend=false, kubeconform -strict, php -l, nginx -t and shellcheck; the nginx rules were also run against a container. Nothing was created in an AWS account, so the offload plugin was not run against S3 and the settings in the plugin section are those of its documentation.

The media library is the largest piece of state that WordPress keeps on disk, and the one that most obviously breaks a cluster: an image uploaded through one pod must be visible from every other pod, survive the pod’s replacement, and be fast to serve. This page is for administrators and platform engineers moving Lumen Press from one server to the cluster described in the Cloud Reference Architecture. It covers the two designs that work, the CDN in front of them, how to move the existing library and how to rebuild image sizes. The general rule that uploads must be shared is in Scaling and High Availability; the S3, EFS and CloudFront services themselves are in Amazon S3, block and file storage and CloudFront.

Two media paths on Kubernetes: offload to S3 behind CloudFront, and a shared EFS volume

What writes to the uploads directory

wp-content/uploads is not only the Media Library. On a real site, these write there:

  • The upload itself, in YYYY/MM/ folders, plus every sub-size that the theme registers (thumbnail, medium, large and the theme’s own), generated by the image editor (Imagick or GD) at upload time. Edits in the image editor add more files.

  • Plugins that generate files: page-builder CSS, form-plugin attachments and exports, SEO sitemaps in some plugins, caching and minifying plugins, backup plugins and WooCommerce’s protected woocommerce_uploads folder for downloadable products.

  • Imports, such as wp import and plugin-driven bulk uploads.

On one server all of these land on one disk. On a cluster, a pod’s own disk is thrown away when the pod is replaced, and another pod does not see it. There are three ways to give every pod the same view:

Offload plugin and S3 Shared EFS volume

How it works

A plugin hooks the upload, copies the files to an S3 bucket, and rewrites attachment URLs to a CDN host. The pods keep only scratch space

Every pod mounts the same network file system at wp-content/uploads, so a file written by one is read by all

WordPress changes

A plugin and its settings in wp-config.php

None. The directory just happens to be network storage

Serving files

CloudFront reads from the private bucket; no pod is involved

nginx in a pod reads from the mount; CloudFront caches in front

Performance

Fast and cacheable; no filesystem latency on the request path

Each uncached read is a network file system read; it is fine behind a CDN

Plugins that expect a local directory

Break unless the plugin copies the files back or they are changed

Work as they did

Failure mode

A bucket policy or role error: uploads fail or links break

Mount target or throughput problems: slow or hung requests

Fit

New sites, large libraries, high traffic

Sites with plugins that read and write files by path; a first step on a short timeline

A fourth option, mounting the bucket as a file system with a FUSE tool, is common in older guides and is not recommended: S3 is an object store without the rename, append and locking behavior that WordPress and its plugins assume, and a mount that hangs hangs the PHP workers with it. Block storage (EBS) is not an option either; a volume attaches to one node in one zone, so it cannot be shared by pods in several zones.

The default for the Lumen Press cluster is the offload plugin and S3 behind CloudFront, with EFS as the fallback for the plugins that need a directory.

Option A: offload to S3

How an offload plugin works

WordPress has no built-in remote media storage. An offload plugin uses core hooks: it filters upload_dir, runs after wp_generate_attachment_metadata and wp_update_attachment_metadata to copy the original and each sub-size, filters wp_get_attachment_url and the image srcset output to produce CDN URLs, and removes the object when the attachment is deleted. It records what it did in post meta, so it knows which attachments are offloaded.

WP Offload Media Lite is the best known. The wordpress.org page described it, on the check date, as copying files uploaded through the media uploader to Amazon S3, DigitalOcean Spaces or Google Cloud Storage, rewriting URLs to the bucket or a CDN, and optionally removing the local copies. It listed three limits of the Lite version: only new uploads are offloaded, existing media is not moved automatically, and files go to the server first before the plugin copies them. Uploading the existing library and managing offloaded files from the Media Library are Pro features. Other offload plugins exist (Media Cloud is one); evaluate each against the checklist below, which is what decides whether a plugin is acceptable on a cluster.

A plugin is acceptable for a cluster when:

  • it offloads every sub-size, and the files created by image-editor edits, and removes them when the attachment is deleted;

  • its settings can be fixed in code (a constant), so that all pods behave alike and nobody changes them in a dashboard that only one pod’s request touches;

  • it uses the pod’s IAM role through the AWS SDK’s default credential chain and does not need long-lived access keys in wp-config.php;

  • it works with a bucket that blocks public access and has ACLs disabled (the default for new buckets);

  • it can be configured with a delivery domain, so URLs point at CloudFront and not at the bucket;

  • its behavior is clear when the upload to S3 fails, because the file is still on the pod’s scratch disk and the request must not report success.

The scratch directory

A pod still writes to wp-content/uploads: WordPress stores the original, runs the image editor on it and writes the sub-sizes there, and only afterwards does the plugin copy them to S3. With a read-only root file system the directory must be a writable volume; an emptyDir with a size limit does it, and the plugin setting remove-local-file deletes each file after the copy. The files in that directory are not the library: they are in flight, and the pod can disappear without loss once the request has finished. The mount in the Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: lumenpress-web
  namespace: lumenpress
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: lumenpress-wordpress
      app.kubernetes.io/component: web
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lumenpress-wordpress
        app.kubernetes.io/component: web
    spec:
      serviceAccountName: lumenpress-wordpress
      securityContext:
        runAsNonRoot: true
        runAsUser: 33
        runAsGroup: 33
      containers:
        - name: php-fpm
          image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/lumenpress/wordpress:<git-sha>
          securityContext:
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
          volumeMounts:
            - name: uploads-scratch
              mountPath: /var/www/html/wp-content/uploads
            - name: tmp
              mountPath: /tmp
      volumes:
        # WordPress writes the original and the sub-sizes here before the plugin copies them to S3.
        - name: uploads-scratch
          emptyDir:
            sizeLimit: 2Gi
        - name: tmp
          emptyDir: {}

The Deployment is a fragment of the one built in Deploying on EKS with Helm; the full chart has the nginx container, probes and the rest. The service account lumenpress-wordpress is the one that gets the bucket role in the next listing. A sizeLimit evicts the pod when a runaway import fills it, which is better than filling the node. Choose the limit from the largest upload the site accepts, times the number of sub-sizes, times the concurrent uploads per pod.

The bucket, CloudFront and the pod role

The listing creates the private bucket, an origin access control, a CloudFront distribution for media.lumenpress.example with a long cache lifetime, the bucket policy that lets only that distribution read, and an IAM role for the pods that can write under the uploads prefix, bound to the service account with EKS Pod Identity. It is the media extract of the infrastructure in EKS Infrastructure with OpenTofu; the ACM certificate must be in us-east-1, as CloudFront requires, and the DNS record is a Route 53 alias to the distribution (see Route 53 and Certificates).

terraform {
  required_version = ">= 1.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

variable "certificate_arn" {
  description = "ACM certificate in us-east-1 for media.lumenpress.example."
  type        = string
}

resource "aws_s3_bucket" "media" {
  bucket = "lumenpress-prod-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
}

resource "aws_s3_bucket_ownership_controls" "media" {
  bucket = aws_s3_bucket.media.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_versioning" "media" {
  bucket = aws_s3_bucket.media.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "media" {
  bucket = aws_s3_bucket.media.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "media" {
  bucket = aws_s3_bucket.media.id

  rule {
    id     = "expire-noncurrent-versions"
    status = "Enabled"

    filter {}

    noncurrent_version_expiration {
      noncurrent_days = 30
    }

    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }
  }
}

resource "aws_cloudfront_origin_access_control" "media" {
  name                              = "lumenpress-prod-media"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

data "aws_cloudfront_cache_policy" "optimized" {
  name = "Managed-CachingOptimized"
}

resource "aws_cloudfront_response_headers_policy" "media" {
  name = "lumenpress-media"

  custom_headers_config {
    items {
      header   = "Cache-Control"
      value    = "public, max-age=31536000, immutable"
      override = false
    }
  }
}

resource "aws_cloudfront_distribution" "media" {
  enabled         = true
  is_ipv6_enabled = true
  aliases         = ["media.lumenpress.example"]
  comment         = "Lumen Press: offloaded uploads"

  origin {
    origin_id                = "media"
    domain_name              = aws_s3_bucket.media.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.media.id
  }

  default_cache_behavior {
    target_origin_id           = "media"
    viewer_protocol_policy     = "redirect-to-https"
    allowed_methods            = ["GET", "HEAD", "OPTIONS"]
    cached_methods             = ["GET", "HEAD"]
    cache_policy_id            = data.aws_cloudfront_cache_policy.optimized.id
    response_headers_policy_id = aws_cloudfront_response_headers_policy.media.id
    compress                   = true
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = var.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

data "aws_iam_policy_document" "media_bucket" {
  statement {
    sid       = "AllowCloudFrontRead"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.media.arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.media.arn]
    }
  }
}

resource "aws_s3_bucket_policy" "media" {
  bucket = aws_s3_bucket.media.id
  policy = data.aws_iam_policy_document.media_bucket.json
}

data "aws_iam_policy_document" "pod_trust" {
  statement {
    actions = ["sts:AssumeRole", "sts:TagSession"]

    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "offload" {
  statement {
    sid       = "ListBucket"
    actions   = ["s3:ListBucket", "s3:GetBucketLocation"]
    resources = [aws_s3_bucket.media.arn]
  }

  statement {
    sid       = "ReadWriteObjects"
    actions   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
    resources = ["${aws_s3_bucket.media.arn}/wp-content/uploads/*"]
  }
}

resource "aws_iam_role" "wordpress_media" {
  name               = "lumenpress-prod-wordpress-media"
  assume_role_policy = data.aws_iam_policy_document.pod_trust.json
}

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

resource "aws_eks_pod_identity_association" "wordpress_media" {
  cluster_name    = "lumenpress-prod"
  namespace       = "lumenpress"
  service_account = "lumenpress-wordpress"
  role_arn        = aws_iam_role.wordpress_media.arn
}

Why each piece is the way it is:

  • Block Public Access and BucketOwnerEnforced. The bucket is never public, and ACLs are disabled, so every object is owned by the bucket owner. Nobody can make a single object public by accident. The plugin must work with this; check it on staging.

  • Origin access control (OAC). CloudFront signs its requests to S3 with SigV4, and the bucket policy accepts only that distribution’s ARN. OAC replaced the older origin access identity; the bucket in an older guide may still use one, and CloudFront covers the migration.

  • The cache policy. Managed-CachingOptimized is a managed policy with a long default lifetime. Uploaded files are effectively immutable: WordPress gives every upload a unique name and an edit creates a new file with a suffix, so a long lifetime is safe. The response headers policy adds Cache-Control with immutable for browsers when the object has none. A plugin that overwrites a file in place ("replace media") needs an invalidation of that path.

  • Versioning and a lifecycle rule. Versioning protects against deletes and overwrites (a restore is a version restore, see Backups and Disaster Recovery); the lifecycle rule expires old versions after 30 days and aborts stale multipart uploads, which keeps the bucket from growing silently.

  • The pod role. s3:GetObject, PutObject and DeleteObject on wp-content/uploads/* only, and listing of the bucket so the SDK can check keys. A compromised pod can alter media but cannot read other prefixes or change the bucket policy. Pod Identity gives the pod temporary credentials through a container credentials endpoint, with no keys in the cluster; the basics are in EKS Clusters and Identity and Access. A plugin that bundles an old AWS SDK may not support this credential source: on staging, upload an image and confirm in CloudTrail that the object was written by the role of the pods.

  • Missing keys return 403. With OAC and no s3:ListBucket for CloudFront, a request for an object that does not exist is a 403, not a 404. Monitor for both.

An alternative that keeps all URLs on the site’s own host serves the uploads path from the main distribution. One distribution has two origins, the WordPress load balancer and the bucket, and a behavior routes /wp-content/uploads/* to the bucket:

terraform {
  required_version = ">= 1.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

variable "origin_domain_name" {
  description = "DNS name of the load balancer that serves the WordPress pods."
  type        = string
  default     = "origin.lumenpress.example"
}

variable "media_bucket_domain_name" {
  type    = string
  default = "lumenpress-prod-media.s3.eu-west-1.amazonaws.com"
}

variable "media_origin_access_control_id" {
  type = string
}

variable "certificate_arn" {
  type = string
}

data "aws_cloudfront_cache_policy" "optimized" {
  name = "Managed-CachingOptimized"
}

data "aws_cloudfront_cache_policy" "disabled" {
  name = "Managed-CachingDisabled"
}

data "aws_cloudfront_origin_request_policy" "all_viewer" {
  name = "Managed-AllViewer"
}

resource "aws_cloudfront_distribution" "site" {
  enabled         = true
  is_ipv6_enabled = true
  aliases         = ["www.lumenpress.example"]
  comment         = "Lumen Press: WordPress origin, uploads from S3"

  origin {
    origin_id   = "wordpress"
    domain_name = var.origin_domain_name

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  origin {
    origin_id                = "media"
    domain_name              = var.media_bucket_domain_name
    origin_access_control_id = var.media_origin_access_control_id
  }

  default_cache_behavior {
    target_origin_id         = "wordpress"
    viewer_protocol_policy   = "redirect-to-https"
    allowed_methods          = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods           = ["GET", "HEAD"]
    cache_policy_id          = data.aws_cloudfront_cache_policy.disabled.id
    origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id
    compress                 = true
  }

  ordered_cache_behavior {
    path_pattern           = "/wp-content/uploads/*"
    target_origin_id       = "media"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    cache_policy_id        = data.aws_cloudfront_cache_policy.optimized.id
    compress               = true
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = var.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

The trade-off is between URLs and isolation. With path routing there is nothing to rewrite in old posts, no CORS and no extra DNS name, and the offload plugin’s delivery domain is www.lumenpress.example with the object prefix wp-content/uploads/. With a separate media host the files are served from a cookieless domain with its own cache, WAF rules and logs, the page distribution cannot affect media caching, and a later move to another CDN is a DNS change. The default behavior of the page distribution above does not cache the HTML at all; page caching rules are a separate decision, covered in Performance and Caching.

The plugin settings

WP Offload Media reads its settings from a constant named AS3CF_SETTINGS, a serialized array, so that they are fixed in code. The listing sets the keys that matter for the cluster; the plugin’s settings-constants page lists the others and which belong to Pro.

<?php
/**
 * wp-config.php fragment: WP Offload Media settings fixed in code, so that every pod behaves the same
 * and nobody changes them in the dashboard. Credentials come from the pod's IAM role (EKS Pod Identity).
 */
define(
	'AS3CF_SETTINGS',
	serialize(
		array(
			'provider'               => 'aws',
			'use-server-roles'       => true,
			'bucket'                 => getenv( 'LUMEN_MEDIA_BUCKET' ) ?: 'lumenpress-prod-media',
			'region'                 => getenv( 'LUMEN_MEDIA_REGION' ) ?: 'eu-west-1',
			'copy-to-s3'             => true,
			'enable-object-prefix'   => true,
			'object-prefix'          => 'wp-content/uploads/',
			'object-versioning'      => false,
			'serve-from-s3'          => true,
			'enable-delivery-domain' => true,
			'delivery-domain'        => 'media.lumenpress.example',
			'force-https'            => true,
			'remove-local-file'      => true,
		)
	)
);
  • use-server-roles tells the plugin to use the credentials of the environment, here the pod’s role, instead of keys.

  • object-prefix of wp-content/uploads/ makes the object key equal the path of the file on disk, which is what lets the same bucket be filled with a plain sync, and the old URL still resolve after a host change.

  • serve-from-s3 and delivery-domain produce https://media.lumenpress.example/wp-content/uploads/...; force-https keeps them secure.

  • remove-local-file deletes the scratch copy after the upload. Turn it off only if a plugin reads the files from disk and the pod has the volume for them.

  • object-versioning adds a timestamp to the key and is off here, because the bucket is versioned.

The plugin is installed from the image, like any plugin, and activated with WP-CLI in the release process, not from the dashboard. Third-party plugin, constants and behavior change between versions; recheck its documentation on each major update.

Option B: a shared EFS volume

Amazon EFS is a managed NFS file system that many pods in many zones can mount read-write. Kubernetes models it as a PersistentVolumeClaim with the access mode ReadWriteMany, provisioned by the Amazon EFS CSI driver. Use it when plugins write files and read them back by path, when an offload plugin cannot be used, or as the first step before the library is moved to S3.

What to set up

  • The file system. Created with OpenTofu with encryption at rest and a mount target in each private subnet (one per Availability Zone), reachable on TCP port 2049 from the node security group. Pick a throughput mode per the EFS documentation; a small file system on bursting throughput can run out of burst credits on a busy site, which shows as slow uploads and slow reads.

  • The driver. Install it as the EKS add-on aws-efs-csi-driver. Its controller needs an IAM role with the AWS managed policy AmazonEFSCSIDriverPolicy, attached with Pod Identity for the service account efs-csi-controller-sa in kube-system. The driver does not support Windows nodes, and dynamic provisioning is not available on Fargate, where static provisioning is used. Do not install amazon-efs-utils on the nodes yourself.

  • A StorageClass and a PVC. Dynamic provisioning with provisioningMode: efs-ap creates an EFS access point for the claim. An access point is a path in the file system with a fixed POSIX identity: every operation through it runs as that user and group, whatever the pod’s own user is.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: efs-uploads
provisioner: efs.csi.aws.com
parameters:
  provisioningMode: efs-ap
  fileSystemId: fs-0123456789abcdef0
  directoryPerms: "0775"
  uid: "33"
  gid: "33"
  basePath: /lumenpress
  subPathPattern: ${.PVC.namespace}/${.PVC.name}
  ensureUniqueDirectory: "false"
reclaimPolicy: Retain
volumeBindingMode: Immediate
mountOptions:
  - tls
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: lumenpress-uploads
  namespace: lumenpress
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: efs-uploads
  resources:
    requests:
      storage: 5Gi

The parameters:

  • uid and gid of 33 are www-data, the user of PHP-FPM and nginx in the official Debian-based image. The pod runs as that user too, so ownership matches without chown in an init container. Because the access point forces the identity, fsGroup is not needed.

  • directoryPerms of 0775 is the mode of the access point’s root directory when the driver creates it.

  • basePath and subPathPattern choose the directory in the file system. With ensureUniqueDirectory: "false" the access point is /lumenpress/lumenpress/lumenpress-uploads (the namespace and the claim name) and is stable if the claim is recreated; the default of true appends a unique ID, so a recreated claim gets an empty directory.

  • reclaimPolicy: Retain keeps the volume when the claim is deleted, so that a mistaken kubectl delete pvc does not remove the library.

  • mountOptions: [tls] mounts with encryption in transit. The driver’s own proxy handles TLS.

  • The claim’s storage request is a required placeholder: EFS is elastic and does not enforce it.

The Deployment mounts the claim at the uploads path. Only that directory is shared; the rest of the container is the image:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: lumenpress-web
  namespace: lumenpress
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: lumenpress-wordpress
      app.kubernetes.io/component: web
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lumenpress-wordpress
        app.kubernetes.io/component: web
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 33
        runAsGroup: 33
        fsGroup: 33
      containers:
        - name: php-fpm
          image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/lumenpress/wordpress:<git-sha>
          securityContext:
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
          volumeMounts:
            - name: uploads
              mountPath: /var/www/html/wp-content/uploads
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: uploads
          persistentVolumeClaim:
            claimName: lumenpress-uploads
        - name: tmp
          emptyDir: {}

The test that matters is whether a file written by one pod is read by another. The script writes a marker through the first web pod, reads it from every web pod and removes it:

#!/usr/bin/env bash
# Confirms that uploads are writable on the shared volume and visible from every web pod.
set -euo pipefail

namespace=lumenpress
marker="probe-$(date +%s).txt"

pods=$(kubectl -n "$namespace" get pods -l app.kubernetes.io/component=web -o name)
first=$(echo "$pods" | head -n 1)

kubectl -n "$namespace" exec "$first" -c php-fpm -- sh -c "echo ok > /var/www/html/wp-content/uploads/$marker"

for pod in $pods; do
  printf '%s: ' "$pod"
  kubectl -n "$namespace" exec "$pod" -c php-fpm -- cat "/var/www/html/wp-content/uploads/$marker"
done

kubectl -n "$namespace" exec "$first" -c php-fpm -- rm "/var/www/html/wp-content/uploads/$marker"

NFS semantics apply. EFS gives close-to-open consistency: a file is visible to other clients after the writer closes it, which fits WordPress uploads, but directory listings and attributes can be cached for a short time on a client, so a plugin that polls a directory sees changes with a delay. Every file operation is a network round trip, so a request that reads many small files is slower than on a local disk. Behind CloudFront, each file is read once per cache lifetime.

Serving files from nginx

With EFS, nginx in the pod serves the files. The rule below does two things. It serves uploads with a long lifetime, and it refuses PHP files in the uploads tree, so that a script that reaches this writable directory is never executed. It was run against an nginx 1.29 container: a text file answered 200, a missing file 404 and a .php file 403.

# Inside the server block of the nginx container: uploads are data, never code.
location ^~ /wp-content/uploads/ {
    # A PHP file that reaches this directory is never executed or served.
    location ~* \.(?:php|phtml|phar|php[0-9])$ {
        deny all;
    }

    access_log off;
    expires 365d;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
}

The nested location matters. A prefix location marked ^~ wins over any regular-expression location at the same level, so a PHP-deny rule written beside it would be skipped; written inside, it is evaluated. The same rule belongs in the nginx of the offload design too, because a scratch directory is writable by PHP, and it is the configuration to keep when the web server and its rules are written in Web Servers: Apache and nginx.

Never put PHP code on EFS

Uploads on EFS are a sound design. Code on EFS is not. A shared volume for wp-content/plugins, wp-content/themes or the WordPress core directory is a recurring suggestion, because it appears to allow installing a plugin once for all pods. Do not do it, for four reasons:

  1. Speed. PHP stats and reads dozens to hundreds of files per request. With OPcache the reads are cached, but revalidation and the autoloaders still touch the file system, and on network storage each touch is a round trip. Sites on NFS code mounts are slow, and the slowness is hard to attribute.

  2. Stale code. Attribute caching and OPcache validate_timestamps mean that different pods can run different versions of a file for a while after an update. A rolling update is built on each pod having one fixed version of the code.

  3. Security. A writable code directory that is executed is the shape of a remote-code-execution attack: any plugin vulnerability that writes a file becomes a shell on every pod, and the file persists. The image is read-only, so the same vulnerability writes nothing executable.

  4. No rollback. A rollback is going back to the previous image. With shared mutable code there is no previous version to return to.

Code goes in the image; uploads go in the shared store; DISALLOW_FILE_MODS makes the dashboard obey.

Migrating the existing library

The bucket and the offload settings handle new uploads. The old library has to be copied, and its URLs have to resolve. The migration steps for the whole site are in Migrating from a Single Server; for the media:

  1. Copy the files with a plain sync. Because object-prefix makes keys equal paths, aws s3 sync from the old uploads directory to s3://lumenpress-prod-media/wp-content/uploads fills the bucket in the layout the plugin expects. Exclude PHP files and cache directories.

  2. Compare the number of local files with the number of objects.

  3. Point existing posts at the new host. With the Lite plugin the old attachments are not in its records, so their URLs in post content and metadata are the old host’s. wp search-replace rewrites them, including inside serialized data, and --skip-columns=guid leaves the GUIDs alone, as WordPress advises. Run it with --dry-run first, and take a database snapshot before the real run. With the same-host design above this step is not needed.

  4. With the Pro plugin, its tool can upload the existing library and record it; the plain sync is still the cheapest transfer of the bytes.

#!/usr/bin/env bash
# Copies the uploads of the old server to the media bucket and points the database at the new host.
# Run the first part on the old server, the second from a pod that has WP-CLI and database access.
set -euo pipefail

bucket=s3://lumenpress-prod-media/wp-content/uploads
uploads=/var/www/html/wp-content/uploads

# 1. On the old server: a dry run, then the copy. Code never goes to the bucket.
aws s3 sync "$uploads" "$bucket" --exclude '*.php' --exclude '*.phtml' --exclude 'cache/*' --dryrun | tail -n 5
aws s3 sync "$uploads" "$bucket" --exclude '*.php' --exclude '*.phtml' --exclude 'cache/*' --only-show-errors

# 2. Compare counts: local files against objects.
find "$uploads" -type f ! -name '*.php' ! -name '*.phtml' ! -path '*/cache/*' | wc -l
aws s3 ls "$bucket/" --recursive --summarize | tail -n 2

# 3. In a pod: rewrite the URLs of existing posts. Dry run first; guid values must not change.
wp search-replace 'https://www.lumenpress.example/wp-content/uploads' \
  'https://media.lumenpress.example/wp-content/uploads' --skip-columns=guid --all-tables --dry-run
wp search-replace 'https://www.lumenpress.example/wp-content/uploads' \
  'https://media.lumenpress.example/wp-content/uploads' --skip-columns=guid --all-tables

Run the first block while the old site is still live and again at the cutover, because sync copies only the difference, and the second run is short. New uploads during the window go to whichever site accepts them; freeze editing for the final run, as the migration page describes.

To move from EFS to S3 later, the same copy runs inside the cluster, from the claim to the bucket, as a Job with read-only access to the volume and the pod role:

apiVersion: batch/v1
kind: Job
metadata:
  name: lumenpress-media-sync
  namespace: lumenpress
spec:
  backoffLimit: 0
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lumenpress-wordpress
        app.kubernetes.io/component: media-sync
    spec:
      restartPolicy: Never
      serviceAccountName: lumenpress-wordpress
      containers:
        - name: aws-cli
          image: public.ecr.aws/aws-cli/aws-cli:<pinned-tag>
          args:
            - s3
            - sync
            - /uploads
            - s3://lumenpress-prod-media/wp-content/uploads
            - --only-show-errors
          volumeMounts:
            - name: uploads
              mountPath: /uploads
              readOnly: true
      volumes:
        - name: uploads
          persistentVolumeClaim:
            claimName: lumenpress-uploads
            readOnly: true

The image is the AWS CLI image from the public ECR gallery; replace <pinned-tag> with a release you have tested, never latest. After the sync, switch the offload settings on, replace the mount with the scratch emptyDir, run the search-and-replace, and keep the volume for a retention period.

Regenerating image sizes as a Job

Image sub-sizes are produced at upload time. When a theme changes its registered sizes, or a plugin adds one, existing images do not have the new sizes until they are regenerated. wp media regenerate does that, and on a cluster it is a Job, not a command on a web pod: it is heavy (Imagick may need hundreds of MiB for a large image), long, and must not compete with page requests.

The options of wp media regenerate as documented: attachment IDs to limit the work, --image_size to regenerate only named sizes, --only-missing to create only absent sizes, --skip-delete to keep the old thumbnails, --delete-unknown to remove thumbnails of sizes that no longer exist, and --yes to skip the confirmation that appears when no IDs are given.

The Job needs the same image as the web pods (the theme and plugins that register the sizes), the same configuration from the secret, the scratch directory (or the EFS claim in that design) and, for the S3 design, the service account with the bucket role so that the offload plugin can copy the new files. A large library is processed in batches, so a failure does not restart the whole library:

#!/usr/bin/env bash
# Regenerates missing image sizes in batches, so that one failure does not repeat the whole library.
# Runs inside the lumenpress-media-regenerate Job.
set -euo pipefail

batch=200
offset=0

while :; do
  ids=$(wp post list --post_type=attachment --post_mime_type=image --orderby=ID --order=ASC \
    --posts_per_page="$batch" --offset="$offset" --format=ids)
  [ -z "$ids" ] && break

  # shellcheck disable=SC2086  # the IDs must be separate arguments
  wp media regenerate $ids --only-missing --yes
  offset=$((offset + batch))
  echo "Processed up to offset $offset"
done

The script is installed in the image as lumen-regenerate-media. The Job runs it once:

apiVersion: batch/v1
kind: Job
metadata:
  name: lumenpress-media-regenerate
  namespace: lumenpress
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lumenpress-wordpress
        app.kubernetes.io/component: media-regenerate
    spec:
      restartPolicy: Never
      serviceAccountName: lumenpress-wordpress
      securityContext:
        runAsNonRoot: true
        runAsUser: 33
        runAsGroup: 33
      containers:
        - name: wp-cli
          image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/lumenpress/wordpress:<git-sha>
          command: ["lumen-regenerate-media"]
          envFrom:
            - secretRef:
                name: lumenpress-wordpress
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              memory: 1Gi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: uploads-scratch
              mountPath: /var/www/html/wp-content/uploads
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: uploads-scratch
          emptyDir:
            sizeLimit: 2Gi
        - name: tmp
          emptyDir: {}
  • backoffLimit: 0 and restartPolicy: Never stop Kubernetes from repeating a half-finished run automatically; look at the log, fix the cause, and run it again, since --only-missing makes the rerun skip finished work.

  • Memory is the usual failure. If a pod is killed with OOMKilled, raise the limit; for very large originals, WordPress’s WP_MAX_MEMORY_LIMIT also applies to the process and sets how much PHP itself may use.

  • Test on one attachment ID first and check that the new sizes appear in the bucket. How a plugin handles regenerated files differs; with an offload plugin, confirm that regenerated sizes are copied and that the old ones are not left behind.

  • Run it off-peak. With the EFS design the I/O competes with the live site for throughput.

Security

  • The bucket is private; CloudFront is the only reader, through OAC; the pods have a role limited to one prefix; no keys exist.

  • Uploads are data. nginx refuses to serve PHP from the uploads tree in both designs. WordPress rejects dangerous file types by default; plugins that enable SVG or other types widen what a user can upload, and an SVG is an XSS vector. Treat them as a risk decision.

  • Private files, such as protected product downloads, must not be put under the public prefix that CloudFront serves. Use a separate prefix with its own behavior and signed URLs or cookies, or let the application stream them. See CloudFront.

  • Encrypt at rest (SSE-S3 above, or a KMS key; EFS encryption at creation) and in transit (HTTPS to CloudFront, the TLS mount option for EFS).

  • Back up the media on its own: bucket versioning and replication, and AWS Backup for EFS. Backups and Disaster Recovery has the plan.

Pitfalls

  • A read-only root file system and no scratch volume. The first upload fails with "Unable to create directory". Mount the scratch volume at wp-content/uploads.

  • Offloading without moving the old library. New images load from the CDN and old ones return 404 or load from the old host.

  • remove-local-file with a plugin that reads from disk. The plugin finds nothing. Test every plugin that handles images, such as optimizers and watermarking tools, with the setting on.

  • Rewriting URLs without a database snapshot. wp search-replace is global; run it with --dry-run first and keep the snapshot.

  • uid mismatch on EFS. Files owned by a different user make uploads fail with permission errors. Set the access point to the pod’s user.

  • Dynamic provisioning on Fargate. Not supported; use static provisioning.

  • Sharing the code directory. See above.

  • Caching mutable files for a year. Safe for unique upload names, wrong for a file replaced in place; invalidate those paths.

  • Hotlinking and bandwidth. A public CDN host is hot-linkable. Add rules in the WAF or a response policy if it matters.

Where older material is outdated

  • Guides that mount a bucket with s3fs or a similar FUSE tool at uploads describe a design with real hazards; use an offload plugin.

  • The offload plugin is called WP Offload Media; older guides call it "Amazon S3 and CloudFront", its earlier name, and show settings for IAM users with access keys in wp-config.php. On EKS, use roles.

  • Origin access identity (OAI) has been replaced by origin access control for private S3 origins.

  • Guides that serve public buckets with a bucket policy of Principal: * are unsafe by today’s defaults; new buckets block public access.

  • Bitnami images and charts with a built-in shared volume are no longer a maintained base; see the Cloud Reference Architecture.