Laravel on k3s, Part 2: CI/CD to Kubernetes

8 min readUpdated on

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.

yaml
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.

yaml
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.

sh
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.

yaml
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:

sh
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.

sh
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:

sh
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.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.