Containers: what belongs in the image, and what must not.
A shared runtime image, site code fetched from git at boot, and durable data kept outside the pod. Follow the boundary, then read a measured boot of the container serving udx.io.
Five things belong outside the image.
The question is never "how do I put WordPress in a container". It is "what does the container own". Draw the line wrong and every replica, redeploy and restore becomes a negotiation with a disk. Here is the line as this site draws it.
Verified on October 12, 2026 (UTC) against the deployed manifest, the base image source and the two running production pods behind udx.io.
In the image
The runtime image used by udx.io
Ubuntu 25.10, a non-root user, and only the capability needed to bind the web port
nginx 1.28, PHP 8.4 with the extensions the sites need, config validated at boot
WP-CLI 2.12, Node 22 for theme builds, git, the APM agent
Fetch the branch, resolve secrets, write certificates, validate PHP-FPM, start. On protected branches nothing is installed at boot: the repository is the truth
uploads/Belongs outsideUploads are durable data. Files written only inside one container are absent from its replicas and disappear when that container is replaced.
Outside the image
fetched, mounted or connected at boot, never copied in
git fetch of one branch, depth 1, into /var/www: core, theme, plugins, mu-plugins. 20,077 tracked files, zero of them in the image
Cloud SQL, MySQL 8.4, regional. 456 MB, 49 tables, backed up nightly with seven days of point-in-time log
One bucket, 863 media items and 1.4 GB including generated sizes, served through an image CDN. Temporary processing files are removed after upload.
One ConfigMap is the only volume. Secrets resolve from Secret Manager at boot and are never written to the repo or the image
Memcached per environment. Logs go to stderr and the cluster collects them
This is the boundary used by udx.io. Baking versioned site code into an immutable image is another valid approach; fetching a moving branch at boot means the runtime image alone does not identify the release.
Six settings that shape a container.
Six annotated excerpts from the production deployment. Some fields are abbreviated for reading; this is not a complete manifest to copy and apply.
image: worker-site:0.62.0The tag is pinned in the repo. A daily job opens a pull request when a newer image is published, so an upgrade is a review, not a surprise. Two sites in our record broke because an environment came back on a different image than the one it was tested on. A version tag still relies on the registry keeping that tag unchanged; an image digest pins the exact artifact.
securityContext:
runAsNonRoot: true
runAsUser: 500
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
add: [NET_BIND_SERVICE]PHP does not need root to serve a page. The container runs as user 500, cannot escalate, and keeps exactly one capability, the one that lets nginx bind port 80.
volumes:
- worker-config (ConfigMap)Runtime configuration arrives as one YAML file. There is no persistent volume claim anywhere in the manifest. If a pod dies, nothing is lost that was not already in git, the database or the bucket.
env:
GIT_BRANCH: production
WP_OBJECT_CACHE: 'true'The same image serves development, staging and production. The branch name and a handful of flags are the only difference between them.
readinessProbe:
httpGet:
path: /status
port: 12030
initialDelaySeconds: 60The probe asks the process supervisor whether nginx and PHP-FPM are running. It does not yet send a request through PHP, which is the stricter check our record argues for. See the boot below.
requests: 1 CPU, 1 GiB
limits: 2 CPU, 2 GiB
replicas: 2 (autoscaler 2 to 50 at 80% CPU)Limits are what the kernel enforces. Requests are what the scheduler reserves. The replica count in the manifest matches the autoscaler floor, because an apply resets the live count to whatever the manifest says.
Image identity and cache behavior follow the Kubernetes image documentation. The timings above are observations from this site.
What happens in the first two minutes.
A container boot is a sequence you can read, not a black box. This one is ours.
Read from the timestamped log of a production pod started on October 5, 2026. The second pod took 74 seconds to Ready.
The 529 MB image was already on the node. With IfNotPresent, cached layers are reused; a cold node adds image-pull time to this sequence.
Database certificates, keys and tokens resolve from Secret Manager into the environment. 22 seconds, serially. The slowest step, and the first thing we would change.
The process supervisor brings up both. There is nothing to serve yet: the document root is empty.
git init, one remote, fetch depth 1, checkout. Eight seconds later the site is at /var/www with no history to carry.
Client certificates are written to disk. Pool overrides from the environment are validated; this site sets none, so it runs the image defaults of 15 workers.
Drop-ins are linked, the object cache is enabled and the debug log is watched. The log reaches WordPress initialization here; this timestamp alone does not prove an HTTP request succeeds.
The supervisor reports nginx and PHP-FPM running after the fixed sixty-second delay. The pod becomes eligible for traffic; this is not an end-to-end page check.
Environment sync, a core checksum verification and the APM agent run after Ready, and PHP-FPM restarts for two seconds at 117 to attach the agent. Honest, and on the list.
Resolve secrets in parallel. Thirteen sequential calls consumed 22 seconds in this boot. Parallelizing independent lookups could shorten it; the result needs measuring before we promise a new boot time.
Probe through PHP. Ask nginx for a PHP file with no database call, not the supervisor for a process list. A site in our record went green 29 seconds early on a process probe and shipped about 1,850 errors a week at scale-up.
Finish before Ready. Checksums, environment sync and the agent restart belong before the probe answers, so that a pod never restarts PHP-FPM while it is in the load balancer.
The most expensive incident in this layer was silent: an init script edited the PHP-FPM pool file as the wrong user, the error was swallowed, and 22 deployments ran the default of 15 workers while everyone believed they were tuned. The fix was not a bigger number. It was a boot that validates every value it is given and refuses to start when one is wrong. Since then the pool is mounted read-only and asserted before PHP-FPM starts.
One runtime, three branches, shared data.
The runtime image is shared. The application branches differ, so this is not the same immutable application build promoted through three environments.
| What | Development | Staging | Production | Across environments |
|---|---|---|---|---|
| Image | worker-site 0.62.0 | worker-site 0.62.0 | worker-site 0.62.0 | same |
| Code | develop-* branch | staging branch | production branch | differs |
| Database | shared with production | shared with production | the shared database | shared |
| Media bucket | shared | shared | the shared media bucket | shared |
| Object cache | own memcached | own memcached | own memcached | differs |
| CDN | own distribution, caching off | own distribution, caching off | own distribution, cached | differs |
| Access | gated at the edge | gated at the edge | public | differs |
A shared database is a decision, not an accident.
This site shares one database across every environment on purpose. The content is the product, one small team edits it, and a change made through staging is meant to be a production change. That is how these chapters were published: written against the staging host, served by production a minute later.
Two incidents in the wider operating record show the cost of this pattern: a development hostname was written into 57 live rows, and activating a plugin on staging created six production tables. Shared content means shared writes, even behind a gated staging URL. Use a clone for schema changes, plugin trials and core upgrades; chapter 10 covers the recovery path.
Where the line was drawn wrong.
Four application-layer incidents from the operating record, each one a line drawn in the wrong place.
Incident 01 / ApplicationReady before PHP was ready+
Bursts of 503 errors, about 1,850 a week, clustered at the minutes the cluster scaled up
CauseThe load balancer health check watched a helper process that was ready in six seconds, while PHP behind it needed thirty-five. New pods took traffic before they could serve it, and the autoscaler flapped between two and four replicas
Evidence and correctionLoad balancer log: 903 of the last 1,000 errors were backend connections closed at scale-up. Fixed by probing the real PHP path and raising the replica floor
View the operating record ↗Incident 15 / ApplicationTuning that never reached PHP+
Out-of-memory kills and 502s in traffic spikes despite per-site PHP tuning
CauseThe init script edited the PHP-FPM pool file as the wrong user, the error was swallowed, and every site silently ran the default of 15 workers
Evidence and correctionReading the live pool config inside the container, not the repository: 22 deployments affected. Fixed by mounting the config read-only and asserting it at boot
View the operating record ↗Incident 17 / ApplicationThe staging and production gap+
Login page returning 502 on production only
CauseThe production web server had 64k FastCGI buffers while staging had 256k, and chunked responses without a content length also made the CDN skip compression
Evidence and correctionA configuration diff between the two environments. Fixed in infrastructure code for both, which is where the drift started
View the operating record ↗Incident 12 / ApplicationAn abandoned add-on meets PHP 8+
Fatal errors on a form page after a PHP upgrade, 120 occurrences in a week
CauseAn abandoned add-on used a function removed in PHP 8
Evidence and correctionApplication monitoring grouped by error. Fixed with a small shim while the add-on was replaced
View the operating record ↗Also in the record, from pull requests
Asked before the first deploy.
Should I use the official WordPress Docker image?
The official image is a useful starting point. Decide how code is versioned and how replicas access media before choosing a persistent-volume layout. Shared storage can work, but introduces its own availability and permissions requirements. This site instead fetches code from git and stores uploads in object storage.
Do I need a persistent volume for wp-content?
Not with this design: media lives in object storage and code is fetched from git. A shared filesystem is another valid architecture, but it becomes a stateful dependency to operate and restore. Inventory plugin data, scheduled jobs and temporary files before removing an existing volume.
Where do uploads go if the filesystem is ephemeral?
To object storage through the media plugin. Ephemeral mode uses local files during processing, then removes them. Stateless mode avoids a persistent uploads directory, but PHP still needs a writable temporary upload location. Chapter 06 explains the choices.
How do I tune PHP-FPM inside a container?
Use the configuration mechanism supported by the image and validate it at boot. Reserve memory for nginx, the supervisor, shared caches and other processes, then size PHP-FPM workers from the remaining budget and measured worker memory under representative load. PHP memory_limit is a per-request ceiling, not a measurement of total worker memory.
How does WP-Cron run when there are several replicas?
Disable the request-spawned cron and run the scheduler from one place: a cluster CronJob or an external scheduler calling the cron endpoint or WP-CLI. Never attach it to a health probe. In our record a probe-driven cron coupled health to job duration and fired once per replica, and an edge redirect silently ate the direct cron requests for weeks.
Running WordPress in a container that still needs a volume?
Tell us what is on the volume and how releases reach production. We can scope the engineering work to separate durable data, make deployments repeatable and verify the boot path.