Deploying a PHP Application to Kubernetes: A GitOps Walkthrough
Deploying a PHP Application to Kubernetes: A GitOps Walkthrough
When you first ship a PHP backend to Kubernetes, the application code is rarely the hard part. What takes time, and iteration, is everything around it: the container, the pipeline, the secrets, the scheduler, and the runtime. This is the sequence of decisions I made deploying a Symfony app on FrankenPHP to EKS with a GitOps stack.
The service talks to external APIs (including an AI provider). That did not change the cluster shape. It did make secret paths, health probes, and a crash-on-boot smoke test non-negotiable.
The stack
- PHP 8.3 / Symfony: application runtime
- FrankenPHP: PHP server on top of Caddy
- Docker: container image
- GitHub Actions: CI/CD
- Terraform + Terragrunt: AWS bootstrap
- Argo CD + Helm: GitOps deploy
- AWS EKS: cluster
- SSM Parameter Store + External Secrets Operator: secrets into pods
1. Bootstrapping AWS with Terragrunt
Before anything runs in the cluster, the AWS side has to exist. I used Terragrunt on top of Terraform: DRY includes, remote state, environment inheritance. Not a second IaC language, a wrapper.
Remote state
State lives in S3, one key per project and environment:
remote_state {
backend = "s3"
config = {
bucket = "<your-tfstate-bucket>"
region = "eu-west-3"
key = "${local.project}/${path_relative_to_include()}/terraform.tfstate"
use_lockfile = true
assume_role = { role_arn = "<deployment-role-arn>" }
}
}
use_lockfile = true stops two applies from corrupting the same state (another engineer, or two CI jobs).
Application bootstrap module
A shared internal module owns the boring part: ECR, IAM, monitoring wiring. Each service does not copy that Terraform.
inputs = {
deployable_to = ["staging", "production"]
monitoring = {
enabled = true
environments = ["staging", "production"]
}
}
2. GitOps with Argo CD and Helm
Once the AWS resources exist, Argo CD watches Git and reconciles the cluster to what the repo declares.
Helm values hierarchy
deploy/argo/backend/
├── Chart.yaml # chart
├── values.yaml # shared defaults
├── staging/values.yaml # staging overrides
└── production/values.yaml # production overrides
A value in values.yaml is inherited unless an environment file overrides it. Divergence is explicit.
Resource allocation
The first profile is intentionally small:
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 512Mi
Requests are what the scheduler guarantees. Limits are the ceiling (CPU throttle, OOM kill on memory). Start low, raise from observed usage. Do not guess production size on day one.
3. The CI/CD pipeline
Four GitHub Actions workflows cover the lifecycle:
| Workflow | Trigger | Purpose |
|---|---|---|
pull-request.yml |
Every PR | Tests, PHPStan, build image, smoke-start the container |
default.yml |
Push to main |
Build, push to ECR, deploy staging |
release_on_tag.yml |
Git tag | Deploy production |
infra-as-code.yml |
Changes under deploy/terragrunt/ |
Plan or apply Terraform |
Docker smoke test in CI
After building the image on a PR, start it for a few seconds and assert it is still running. A docker stop || true alone is not a test: a crash still looks green.
- name: Build Docker image (no push)
uses: docker/build-push-action@v6
with:
push: false
load: true
tags: my-app-backend:test
- name: Test container stays up
run: |
docker run --rm -d --name test-container my-app-backend:test
sleep 5
docker inspect -f '' test-container | grep -qx true
docker stop test-container
This catches what PHPStan will not: a missing extension, a bad entrypoint, an env var the process needs at boot.
Static analysis gate
Every PR runs PHPStan before merge. On a PHP codebase that calls external APIs, type mistakes that used to explode at runtime show up on the PR.
4. Container security hardening
The Dockerfile went through several iterations. Each change had a concrete failure behind it.
Do not let FrankenPHP terminate TLS
FrankenPHP (Caddy) defaults to port 443 and automatic certificates. Behind a Kubernetes ingress that is the wrong split:
- The ingress already terminates TLS
- Binding 443 inside the pod wants extra privilege (
CAP_NET_BIND_SERVICEor root)
The fix we used is a single environment variable:
ENV SERVER_NAME=:80
SERVER_NAME is how Caddy sets the listen address. :80 is plain HTTP, no automatic certs. TLS stays on the ingress.
Drop capabilities you no longer need
FrankenPHP ships with CAP_NET_BIND_SERVICE on the binary so it can bind 443 as non-root. After moving off 443 and sitting behind the ingress, we dropped it:
RUN setcap -r /usr/local/bin/frankenphp
Least privilege: keep only what the process still needs.
Run as non-root
ARG USER=www-data
RUN \
setcap -r /usr/local/bin/frankenphp; \
chown -R ${USER}:${USER} /config/caddy /data/caddy /app
USER ${USER}
The process is www-data (UID 33 on Debian). chown covers:
/config/caddyand/data/caddy: Caddy runtime storage/app: app cache and logs
Without that, the image builds, then the process dies at runtime on Permission denied.
Align UIDs between Docker and Kubernetes
Dockerfile USER www-data (UID 33) is not enough. The pod must declare the same UID:
podSecurityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
runAsUser/runAsGroup: UID/GID of the processfsGroup: kubelet chowns mounted volumes to this GID at start
If these drift, volume mounts fail even when the Dockerfile is correct.
5. Secrets with External Secrets Operator
API keys live in AWS SSM Parameter Store. External Secrets Operator watches ExternalSecret objects, copies values into Kubernetes Secrets, then the chart injects them as env vars.
externalSecrets:
clusterSecretStore: <your-cluster-secret-store>
params:
- key: /param/my-project/staging/parameters/api_client_secret
name: APP_API_CLIENT_SECRET
- key: /param/my-project/staging/parameters/service_api_key
name: APP_SERVICE_API_KEY
The failure mode I hit: Helm paths used a prefix that did not match SSM. ESO did not fail the deploy. The pod started, the env var was missing, the error showed up three layers later in application logs.
Lesson: grep the live SSM path before the first deploy. A wrong path is not a CI error.
6. CronJob for the scheduler
Background work is a Kubernetes CronJob in the same chart:
cronJobs:
initiate-calls:
schedule: "0 7-15/2 * * 1-5" # every 2h, Mon-Fri, 7am-3pm
command: "php bin/console app:my-command"
restartPolicy: Never
image:
fromApp: true
imagePullPolicy: IfNotPresent
fromApp: true: same image as the web deployment. Right default whenbin/consoleand the HTTP server share the codebase.imagePullPolicy: IfNotPresent: skip a pull on every tick only if the tag is immutable (digest or a unique tag).latestplusIfNotPresentwill happily run a stale image.
To disable a CronJob without deleting it, set a schedule that never fires (31 February). The Helm object stays; Kubernetes does not trigger it. Restore the real schedule to turn it back on.
7. Health probes and autoscaling
Probes
readinessProbe:
failureThreshold: 3
periodSeconds: 10
livenessProbe:
failureThreshold: 5
periodSeconds: 20
- Readiness: traffic only after the probe passes. 3 failures times 10s = 30s before the pod leaves the Service.
- Liveness: Kubernetes restarts the pod. 5 times 20s = 100s, so cache warmup does not look like a crash loop.
Both hit a dedicated endpoint (for example /check/liveness), not /.
HPA
hpa:
maxReplicas: 3
minReplicas: 1
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
At least one replica, up to three when CPU exceeds 70% or memory exceeds 80%. Staging was maxReplicas: 1. Staging should test the app, not the autoscaler.
What I would keep doing
- FrankenPHP behind an ingress:
SERVER_NAME=:80. Default TLS on 443 fights ingress TLS. - UID alignment is mandatory. Dockerfile
USER,podSecurityContext, and volumefsGroupmust be the same UID/GID. - Verify SSM paths before deploy. A typo is silent at apply time and loud at runtime.
- CronJobs reuse the app image (
fromApp: true) in this layout. - Smoke-test the image in CI by asserting the process is still running, not by ignoring
docker stop. - Start probes loose, tighten later. Easier than debugging restart loops on day one.
- Cap staging at 1 replica. Do not rehearse HPA there.