Laravel on k3s — Part 1 · Part 2 · Part 3 · Part 4
The first deployment proved that a Deployment can receive traffic. This part
makes that change repeatable: a pipeline tests source, builds one immutable
image, runs a separately visible migration, changes the workload, and verifies
the public path. It deliberately does not grant CI a human administrator's
kubeconfig or make latest the release identifier.
The safety contract is portable across GitLab CI, GitHub Actions, and another runner. Production credentials are protected; build and cluster identities are separate; the deployed artifact is recorded; readiness is observed; and an operator has a documented recovery path. The runner syntax is incidental. If a release cannot answer which image digest is handling traffic, it is not yet a traceable release.
Build once and promote the same artifact
Use a commit SHA tag for readability and capture the pushed digest as the authoritative identity. Do not rebuild the same commit for production: the base image, package mirror, or build time can change the bytes. Staging should test the exact image production will receive. This GitLab example writes the tag to a dotenv artifact so later jobs cannot accidentally choose a different value.
stages: [test, build, migrate, deploy, verify]
variables:
IMAGE_TAG: "$CI_COMMIT_SHA"
test:
stage: test
script:
- docker compose run --rm app php artisan test --compact
build:
stage: build
script:
- docker build --pull -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" .
- docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
- printf 'DEPLOY_IMAGE=%s\n' "$CI_REGISTRY_IMAGE:$IMAGE_TAG" > image.env
artifacts:
reports:
dotenv: image.env
deploy-production:
stage: deploy
environment: production
when: manual
needs: [build]
script:
- test -n "$DEPLOY_IMAGE"
- kubectl -n storefront set image deployment/web web="$DEPLOY_IMAGE"
- kubectl -n storefront annotate deployment/web "release.example.com/sha=$CI_COMMIT_SHA" --overwrite
- kubectl -n storefront rollout status deployment/web --timeout=180s
The manual gate is not a substitute for review; it is a narrow production
approval after protected-branch checks pass. In a promotion flow, make the
staging and production jobs consume the same DEPLOY_IMAGE artifact. Better
still, resolve and record the registry digest after the push when the registry
and runner support it. A SHA tag is immutable only if the registry policy
prevents users from overwriting it.
Give CI an identity, not an administrator's credentials
A registry credential only needs permission to push the application's images.
The cluster identity needs permission only inside storefront to create and
observe the migration Job, patch the web Deployment, and inspect its Pods and
Events. A token that can modify every namespace turns a compromised runner into
a cluster incident.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: storefront
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deployer
namespace: storefront
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "watch"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "create", "delete", "watch"]
- apiGroups: [""]
resources: ["pods", "pods/log", "events"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer
namespace: storefront
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: storefront
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ci-deployer
How the runner authenticates is an infrastructure decision: a short-lived OIDC
token is preferable to a copied long-lived kubeconfig. Whichever mechanism you
use, put it in protected environment variables, rotate it, and test it with
kubectl auth can-i before release day. Do not commit a bearer token, even in
an encrypted-looking base64 field.
kubectl -n storefront auth can-i patch deployment/web
kubectl -n storefront auth can-i create jobs.batch
kubectl -n storefront auth can-i get secrets
The expected answer to the last command is no. CI can reference the runtime
Secret already present in the namespace, but it does not need to read its
values to create an application container.
Treat migrations as a release step with a name
Run migrations once, before web pods change, using the same immutable image.
The Job name includes the short commit SHA so a completed Job cannot be silently
reused. Laravel's migrate --force confirms a non-interactive production run;
it does not make schema or data changes reversible. Write migrations by the
expand-contract rule: add a nullable column or new table first, deploy code
that works with both shapes, backfill asynchronously, and only later remove the
old path.
apiVersion: batch/v1
kind: Job
metadata:
name: migrate-3f18d7c
namespace: storefront
labels:
app: storefront
release: 3f18d7c
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/acme/store:3f18d7c
imagePullPolicy: IfNotPresent
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
envFrom:
- secretRef:
name: storefront-runtime
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
Render the image and name from CI rather than applying this literal example. For example, create a temporary manifest from a reviewed template, then wait for it and show its logs even when it fails:
job="migrate-${CI_COMMIT_SHORT_SHA}"
kubectl -n storefront delete job "$job" --ignore-not-found
sed -e "s|IMAGE_PLACEHOLDER|$DEPLOY_IMAGE|g" \
-e "s|JOB_PLACEHOLDER|$job|g" k8s/migrate-job.yaml | kubectl apply -f -
if ! kubectl -n storefront wait --for=condition=complete "job/$job" --timeout=180s; then
kubectl -n storefront logs "job/$job" --all-containers=true || true
kubectl -n storefront describe "job/$job"
exit 1
fi
kubectl -n storefront logs "job/$job" --all-containers=true
Do not make application startup run php artisan migrate. During a scale-out,
two replicas can race; during a rollback, old code can start against a changed
schema. Nor should a failed migration be retried blindly: inspect its logs,
understand whether it wrote partial data, and make an explicit corrective
release or restore decision.
Verify the deploy and choose the right rollback
After a successful Job, update the web image, wait for rollout, and test the public hostname. Verify both control-plane state and user-visible traffic. A green readiness probe alone does not prove DNS, TLS, ingress routing, or the expected release image.
kubectl -n storefront set image deployment/web web="$DEPLOY_IMAGE"
kubectl -n storefront rollout status deployment/web --timeout=180s
kubectl -n storefront get pods -l app=storefront-web \
-o custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,IMAGE:.spec.containers[0].image
curl --fail --show-error --retry 3 https://shop.example.com/up
kubectl -n storefront rollout history deployment/web
If readiness or the public check fails, stop the pipeline and collect Events, pod descriptions, and logs before changing the next variable. Roll back the Deployment only after confirming that the migration remains compatible with the previous image:
kubectl -n storefront rollout undo deployment/web
kubectl -n storefront rollout status deployment/web --timeout=180s
curl --fail --show-error https://shop.example.com/up
rollout undo changes the pod template; it does not undo the database Job.
That is the central reason for expand-contract changes and for avoiding
destructive data migrations in the same release as a web change. If the schema
is not backward compatible, the incident requires a deliberate recovery plan,
not an optimistic Kubernetes command.
When this pipeline is too much—or not enough
For a single application with rare releases, a protected manual deploy script that records the image SHA can be safer than a half-built CI/CD system. The alternative is not clicking buttons on a server; it is a small, reviewed, repeatable procedure with the same artifact and verification rules. Conversely, this namespace-scoped pipeline is not enough for multi-cluster promotion, GitOps reconciliation, signed artifacts, or regulatory change control. Those needs may justify Argo CD, Flux, a deployment controller, image signing, and a separate audit design.
Do not use CI as ad-hoc production shell access. An emergency procedure may be necessary, but it must leave an audit record and be reconciled back into Git; otherwise the next declarative release will erase an unexplained fix. The next part turns the pipeline's signals into routine operating checks, backup evidence, and incident practice.
The most useful test of this process is not “did today's deploy pass?” but “can another person reconstruct its decisions in a month?” They should find the commit, image digest, migration result, Deployment change, rollout status, and public-check result without logging in to a server as root. If any evidence is missing, do not immediately add another tool. First record the missing signal in the pipeline and rehearse a simple rollback on staging. That is how automation reduces risk instead of merely accelerating the moment a mistake reaches customers.