Laravel on k3s — Part 1 · Part 2 · Part 3 · Part 4
k3s is a lightweight Kubernetes distribution that is appropriate for a single VPS when you need declarative workloads, probes and controlled rollouts. It is not a shortcut around backups, capacity planning or Linux administration.
Create a namespace, deploy immutable application images, and add readiness plus liveness probes before exposing traffic. A readiness probe decides whether a pod receives traffic; keep it cheap and dependent only on requirements for serving a request. Put persistent data on explicitly selected storage and back it up independently of manifests. Traefik ingress terminates TLS and routes hostnames; the Laravel container should serve only its application concern.
The first milestone is deliberately small: one healthy deployment, a service, ingress and a documented rollback with kubectl rollout undo. Do not add databases and auxiliary services until this path is observable.
Decide whether k3s is the right boundary
k3s earns its operational cost when several independently deployed workloads need a consistent rollout, service discovery and isolation model. A single Laravel container with a managed database is often simpler on Docker Compose or a managed platform. Kubernetes does not make a one-node VPS highly available: the node, its disk and its network are still one failure domain. Choose it for a repeatable platform, not because a manifest looks more professional.
Assume an Ubuntu VPS with a firewall allowing SSH and HTTPS only. Install k3s from its release documentation, keep the generated kubeconfig private, and immediately verify the node rather than treating a successful installer exit code as evidence:
curl -sfL https://get.k3s.io | sh -
sudo k3s kubectl get nodes -o wide
sudo k3s kubectl get pods -A
sudo install -m 600 /etc/rancher/k3s/k3s.yaml /root/kubeconfig.yaml
Do not paste that kubeconfig into CI or commit it. Later, CI receives a dedicated service-account token with a narrowly scoped Role. For now create an application namespace and an immutable image reference. registry.example.com/acme/store:3f18d7c must be an image that already exists; latest cannot provide a meaningful rollback.
A small but valid workload
The image below exposes a Laravel health route that does not call an external payment provider. Readiness proves that this pod can take traffic; liveness only proves that the PHP process is not stuck. Do not make liveness query the database: a database outage should remove traffic through readiness, not cause every healthy web process to restart.
apiVersion: v1
kind: Namespace
metadata:
name: storefront
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: storefront
spec:
replicas: 2
selector:
matchLabels: { app: storefront-web }
template:
metadata:
labels: { app: storefront-web }
spec:
containers:
- name: web
image: registry.example.com/acme/store:3f18d7c
imagePullPolicy: IfNotPresent
ports: [{ containerPort: 8080, name: http }]
envFrom: [{ secretRef: { name: storefront-runtime } }]
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
readinessProbe:
httpGet: { path: /up, port: http }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /up, port: http }
initialDelaySeconds: 20
periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata: { name: web, namespace: storefront }
spec:
selector: { app: storefront-web }
ports: [{ port: 80, targetPort: http, name: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: storefront
namespace: storefront
spec:
ingressClassName: traefik
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: web, port: { name: http } } }
Apply and observe the rollout, not just the API response:
kubectl apply -f k8s/base.yaml
kubectl -n storefront rollout status deployment/web --timeout=120s
kubectl -n storefront get pods,svc,ingress
kubectl -n storefront describe deployment/web
envFrom is intentionally a reference, not a secret manifest. Create runtime values outside Git with a secret manager or a protected deployment step. Kubernetes Secrets are base64-encoded by default; they are not a safe place for unrestricted production credentials.
Failure path before feature work
A successful rollout gives you a revision. Make the rollback concrete while no incident depends on it:
kubectl -n storefront rollout history deployment/web
kubectl -n storefront rollout undo deployment/web --to-revision=1
kubectl -n storefront rollout status deployment/web --timeout=120s
If a pod remains pending, inspect kubectl describe pod and events before changing replicas. The usual causes are an unavailable image, a missing secret, a probe returning the wrong status, or requests exceeding node capacity. Restarting hides the clue.
This part deliberately leaves MySQL, Redis, Horizon and uploads outside the cluster. Stateful services require a backup and restore story, not merely a PVC. Part 2 adds a traceable deployment pipeline; Part 3 makes the operating checks and recovery evidence explicit.
The container is part of the contract
The manifest assumes that the image starts a web server as a non-root user and
does not run php artisan migrate when the process starts. A web pod can be
recreated at any time; coupling schema changes to its start makes two replicas
race and makes a rollback dangerous. Build assets and Composer dependencies in
the image, then make configuration an environment concern:
FROM php:8.5-cli-alpine AS application
WORKDIR /var/www/html
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --no-interaction --no-scripts
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev \
&& addgroup -S laravel && adduser -S laravel -G laravel \
&& chown -R laravel:laravel storage bootstrap/cache
USER laravel
EXPOSE 8080
CMD ["php", "artisan", "octane:start", "--server=frankenphp", "--host=0.0.0.0", "--port=8080"]
This is an example, not an instruction to adopt Octane. PHP-FPM behind nginx is also valid. What matters is one foreground process, a documented port, writable directories with the least privilege necessary, and an image that can be run locally with the same environment names. If the application uses Octane, audit stateful singletons and static state before scaling it; Kubernetes replicas do not repair request-state leaks.
Validate the traffic path and its limits
Point DNS only after the ingress receives an address and test the hostname, not a pod IP. A direct curl to a pod can succeed while TLS, host matching or proxy headers are wrong:
kubectl -n storefront port-forward service/web 8080:80
curl --fail http://127.0.0.1:8080/up
curl --fail --resolve shop.example.com:443:203.0.113.10 https://shop.example.com/up
kubectl -n storefront logs deployment/web --tail=100
Set a real TLS issuer and a redirect policy before accepting customer traffic; the exact configuration depends on whether Traefik, cert-manager or an external load balancer owns certificates. Also set a backup, patching and access-review cadence before calling this production. A healthy deployment is only the first observable unit, not proof of disaster recovery or security.
Make the rollout behaviour explicit
The default rolling-update settings are easy to overlook until a small node
cannot schedule the extra pod. State the availability trade-off in the
Deployment instead. With two replicas, maxUnavailable: 0 preserves capacity
during an ordinary image change, while maxSurge: 1 allows one additional pod
only if the node has room for its resource requests. On a single VPS that may
mean the rollout waits rather than taking a healthy pod down. That is usually
the safer failure mode.
spec:
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: web
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
Put those fields into the preceding Deployment rather than applying this
fragment by itself. The short preStop delay gives the endpoints controller a
moment to stop routing new requests before the process exits. It does not make
in-flight work durable. A long request, queue job, or WebSocket connection
needs its own shutdown design and a timeout that fits the application.
Create the referenced runtime secret through a protected deployment mechanism. This command is intentionally an example for a terminal with values supplied by a secret manager; it does not put credentials in a YAML file:
kubectl -n storefront create secret generic storefront-runtime \
--from-literal=APP_ENV=production \
--from-literal=APP_KEY="$APP_KEY" \
--from-literal=DB_CONNECTION=mysql \
--from-literal=DB_HOST="$DB_HOST" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n storefront get secret storefront-runtime
Do not use this literal form in shell history on a shared bastion. Prefer an external-secrets controller, a CI integration that masks values, or a locally secured input mechanism. Also remember that updating a Secret does not restart pods that consumed it as environment variables. Deliberately trigger a rollout after a credential rotation and verify that the new pods become ready.
A first-deployment runbook
Before declaring this milestone finished, perform the same checks an operator
will perform on a bad day. Confirm that every replica is ready, that the
Service has endpoints, and that an external request reaches the expected image.
The image ID in describe matters: an HTTP 200 from an old pod is not a
successful deploy.
kubectl -n storefront get deployment web
kubectl -n storefront get endpoints web
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
kubectl -n storefront get events --sort-by=.lastTimestamp | tail -n 20
curl --fail --show-error https://shop.example.com/up
If the verification fails, stop changing several variables at once. Capture
the deployment image, Events, pod description and recent logs; then either fix
the manifest in Git and make a new immutable image, or return to the known
good revision. kubectl rollout undo reverses only the Deployment template.
It cannot restore a deleted PVC, revert an irreversible migration, or recover
data written by the faulty release. That boundary is why stateful components
and database migrations belong in a later, separately rehearsed step.
When this is the wrong first deployment
Do not introduce k3s solely to run one conventional Laravel website. Docker Compose with a managed database and a tested backup procedure is easier to operate, has fewer moving permissions, and is often a better first production system. A managed container platform is also a good alternative when the team needs managed TLS, autoscaling, and node patching more than Kubernetes APIs.
k3s becomes a sensible boundary when the team accepts ownership of upgrades, network policy, image retention, node monitoring, and recovery drills—and benefits from deploying several services with the same contract. Start with this narrow workload so that choice is supported by observed evidence, not by an impressive-looking cluster diagram.