Series Navigation
← Part 10: CI/CD Integration — GitLab, GitHub Actions and Docker
Why Run Playwright in Kubernetes?
CI runners (GitHub Actions, GitLab runners) are convenient but limited. Once your test suite grows to 500+ tests, you hit real constraints:
- Fixed parallelism — a runner has a fixed number of CPUs; you can't scale beyond it
- No elastic scaling — you pay for the runner whether tests are running or not
- Shared resource contention — multiple pipelines competing for the same runner
- No pod-level isolation — one flaky test can starve resources for others
Kubernetes solves all of this. Each shard runs in its own pod with dedicated CPU and memory. Scale to 20 pods for a big suite, scale to zero when idle, and collect results centrally.
The Architecture
┌─────────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │ Pod 4 │ │
│ │ shard 1/4│ │ shard 2/4│ │ shard 3/4│ │shard4/4│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ │ │ │ │ │
│ └──────────────┴──────────────┴──────────────┘ │
│ │ │
│ ┌──────────────────┐ │
│ │ Merge Pod │ │
│ │ (HTML report) │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌───────────────────────┐ │
│ │ PVC / S3 / GCS │ │
│ │ (shared report store)│ │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Step 1 — Build the Docker Image
Production Dockerfile
# Dockerfile
# ── Stage 1: Install dependencies ─────────────────────────────────────────
FROM node:20-slim AS deps
WORKDIR /app
# Copy package files first — maximises Docker layer caching
COPY package*.json ./
RUN npm ci
# ── Stage 2: Playwright runner ─────────────────────────────────────────────
FROM mcr.microsoft.com/playwright:v1.40.0-jammy AS runner
# Create a non-root user — never run tests as root in production
RUN groupadd --gid 1001 playwright && \
useradd --uid 1001 --gid playwright \
--shell /bin/bash --create-home playwright
WORKDIR /app
# Copy node_modules from the deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy project source with correct ownership
COPY --chown=playwright:playwright . .
# Install browsers for the non-root user
RUN npx playwright install chromium firefox webkit
# Drop to non-root
USER playwright
# Default command — overridden by the Kubernetes Job spec
CMD ["npx", "playwright", "test"]
.dockerignore
node_modules/
.git/
.auth/
test-results/
playwright-report/
blob-reports/
*.log
.env*
!.env.example
Build and Push
# Build the image
docker build -t your-registry.io/playwright-tests:latest .
# Tag with commit SHA for full traceability
SHA=$(git rev-parse --short HEAD)
docker build -t your-registry.io/playwright-tests:$SHA .
# Push both tags
docker push your-registry.io/playwright-tests:latest
docker push your-registry.io/playwright-tests:$SHA
Verify the image locally
# Run all tests inside the container
docker run --rm \
-e BASE_URL=https://staging.yourapp.com \
-e CI=true \
your-registry.io/playwright-tests:latest \
npx playwright test --project=chromium
# Run a specific shard
docker run --rm \
-e BASE_URL=https://staging.yourapp.com \
your-registry.io/playwright-tests:latest \
npx playwright test --shard=1/4
Step 2 — Kubernetes Secrets
Never put credentials in manifests or ConfigMaps. Use Kubernetes Secrets:
# Create from literal values
kubectl create secret generic playwright-credentials \
--from-literal=TEST_USER_EMAIL=testuser@yourapp.com \
--from-literal=TEST_USER_PASSWORD=TestPassword123 \
--from-literal=ADMIN_EMAIL=admin@yourapp.com \
--from-literal=ADMIN_PASSWORD=AdminPassword123 \
--namespace=testing
# Or create from your .env.test file
kubectl create secret generic playwright-credentials \
--from-env-file=.env.test \
--namespace=testing
# Verify
kubectl get secret playwright-credentials -n testing
Step 3 — ConfigMap for Non-Secret Config
# k8s/playwright-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: playwright-config
namespace: testing
data:
BASE_URL: "https://staging.yourapp.com"
CI: "true"
NODE_ENV: "test"
PLAYWRIGHT_WORKERS: "4"
TOTAL_SHARDS: "4"
kubectl apply -f k8s/playwright-configmap.yaml
Step 4 — PersistentVolumeClaim for Shared Reports
All shard pods need to write their blob reports to a shared location so the merge job can combine them:
# k8s/playwright-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: playwright-reports
namespace: testing
spec:
accessModes:
- ReadWriteMany # multiple pods write simultaneously
storageClassName: standard
resources:
requests:
storage: 10Gi
kubectl apply -f k8s/playwright-pvc.yaml
Storage class note:
ReadWriteManyrequires NFS, AWS EFS, Azure Files, or GCS Fuse. For a single-node cluster you can useReadWriteOnce— just run shards sequentially.
Step 5 — The Shard Job Template
This is the core resource. Each shard gets its own Job:
# k8s/playwright-shard-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: playwright-shard-SHARD_INDEX # replaced per shard
namespace: testing
labels:
app: playwright
run-id: "RUN_ID" # replaced per run
spec:
backoffLimit: 1 # retry the pod once on failure
ttlSecondsAfterFinished: 3600 # auto-cleanup after 1 hour
template:
metadata:
labels:
app: playwright-shard
run-id: "RUN_ID"
spec:
restartPolicy: Never
# Wait for the app under test to be ready before starting
initContainers:
- name: wait-for-app
image: curlimages/curl:8.1.0
command:
- sh
- -c
- |
echo "Waiting for app at $BASE_URL..."
until curl -sf "$BASE_URL/health"; do
sleep 5
done
echo "App is ready. Starting tests."
envFrom:
- configMapRef:
name: playwright-config
containers:
- name: playwright
image: your-registry.io/playwright-tests:latest
imagePullPolicy: Always
# Run this specific shard
command:
- npx
- playwright
- test
- "--shard=SHARD_INDEX/TOTAL_SHARDS"
- "--reporter=blob"
resources:
requests:
memory: "1Gi"
cpu: "1000m"
limits:
memory: "2Gi"
cpu: "2000m"
# Non-secret config from ConfigMap
envFrom:
- configMapRef:
name: playwright-config
# Secrets injected as individual env vars
env:
- name: TEST_USER_EMAIL
valueFrom:
secretKeyRef:
name: playwright-credentials
key: TEST_USER_EMAIL
- name: TEST_USER_PASSWORD
valueFrom:
secretKeyRef:
name: playwright-credentials
key: TEST_USER_PASSWORD
- name: ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: playwright-credentials
key: ADMIN_EMAIL
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: playwright-credentials
key: ADMIN_PASSWORD
# Write blob reports to the shared volume
volumeMounts:
- name: reports
mountPath: /app/blob-reports
volumes:
- name: reports
persistentVolumeClaim:
claimName: playwright-reports
Step 6 — The Merge Job
After all shards finish, this job combines their blob reports into one HTML report:
# k8s/playwright-merge-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: playwright-merge-RUN_ID
namespace: testing
labels:
app: playwright-merge
run-id: "RUN_ID"
spec:
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
containers:
- name: merge
image: your-registry.io/playwright-tests:latest
command:
- sh
- -c
- |
echo "Merging blob reports..."
npx playwright merge-reports \
--reporter html,junit \
/app/blob-reports
echo "Report generated at /app/playwright-report"
ls -la /app/playwright-report
resources:
requests:
memory: "512Mi"
cpu: "500m"
volumeMounts:
- name: reports
mountPath: /app/blob-reports
- name: html-report
mountPath: /app/playwright-report
volumes:
- name: reports
persistentVolumeClaim:
claimName: playwright-reports
- name: html-report
persistentVolumeClaim:
claimName: playwright-html-report
Step 7 — The Orchestration Script
A shell script that creates all shard jobs, waits for them, then triggers the merge:
#!/bin/bash
# scripts/run-playwright-k8s.sh
set -euo pipefail
TOTAL_SHARDS=${1:-4}
RUN_ID="pw-$(date +%Y%m%d-%H%M%S)"
NAMESPACE="testing"
IMAGE_TAG=${IMAGE_TAG:-latest}
echo "═══════════════════════════════════════════════"
echo " Playwright K8s Run"
echo " Run ID: $RUN_ID"
echo " Total Shards: $TOTAL_SHARDS"
echo " Image Tag: $IMAGE_TAG"
echo "═══════════════════════════════════════════════"
# ── 1. Update image tag in ConfigMap ─────────────────────────────────────────
kubectl patch configmap playwright-config -n "$NAMESPACE" \
--patch "{\"data\":{\"IMAGE_TAG\":\"$IMAGE_TAG\"}}"
# ── 2. Spin up all shard jobs in parallel ─────────────────────────────────────
echo "Launching $TOTAL_SHARDS shard jobs..."
for i in $(seq 1 "$TOTAL_SHARDS"); do
JOB_NAME="playwright-shard-${RUN_ID}-${i}"
# Substitute placeholders in the template
sed \
-e "s/SHARD_INDEX/$i/g" \
-e "s/TOTAL_SHARDS/$TOTAL_SHARDS/g" \
-e "s/RUN_ID/$RUN_ID/g" \
-e "s|your-registry.io/playwright-tests:latest|your-registry.io/playwright-tests:$IMAGE_TAG|g" \
k8s/playwright-shard-job.yaml | \
sed "s/name: playwright-shard-SHARD_INDEX/name: $JOB_NAME/" | \
kubectl apply -f - -n "$NAMESPACE"
echo " ✓ Launched shard $i/$TOTAL_SHARDS → $JOB_NAME"
done
# ── 3. Wait for all shard jobs to complete ────────────────────────────────────
echo ""
echo "Waiting for all shards to complete..."
FAILED=0
for i in $(seq 1 "$TOTAL_SHARDS"); do
JOB_NAME="playwright-shard-${RUN_ID}-${i}"
# Wait up to 30 minutes per shard
if kubectl wait job/"$JOB_NAME" \
--for=condition=complete \
--timeout=1800s \
-n "$NAMESPACE" 2>/dev/null; then
echo " ✓ Shard $i completed"
else
echo " ✗ Shard $i FAILED"
FAILED=$((FAILED + 1))
# Dump logs from failed pod for debugging
POD=$(kubectl get pods -n "$NAMESPACE" -l "job-name=$JOB_NAME" -o jsonpath='{.items[0].metadata.name}')
echo " --- Logs from $POD ---"
kubectl logs "$POD" -n "$NAMESPACE" --tail=50
fi
done
# ── 4. Run merge job regardless of shard failures ─────────────────────────────
echo ""
echo "Merging reports..."
MERGE_JOB="playwright-merge-$RUN_ID"
sed -e "s/RUN_ID/$RUN_ID/g" k8s/playwright-merge-job.yaml | \
kubectl apply -f - -n "$NAMESPACE"
kubectl wait job/"$MERGE_JOB" \
--for=condition=complete \
--timeout=300s \
-n "$NAMESPACE"
echo " ✓ Reports merged"
# ── 5. Exit with failure if any shard failed ──────────────────────────────────
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "✗ $FAILED shard(s) failed. Check logs above."
exit 1
fi
echo ""
echo "✓ All $TOTAL_SHARDS shards passed. Run ID: $RUN_ID"
Make it executable:
chmod +x scripts/run-playwright-k8s.sh
Run it:
# 4 shards with latest image
./scripts/run-playwright-k8s.sh 4
# 8 shards with a specific image tag
IMAGE_TAG=abc1234 ./scripts/run-playwright-k8s.sh 8
Step 8 — Using a Kubernetes Job with completionMode: Indexed
A cleaner approach than separate jobs: use a single indexed Job that Kubernetes scales automatically:
# k8s/playwright-indexed-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: playwright-run
namespace: testing
spec:
completions: 4 # total number of pods to run
parallelism: 4 # run all 4 simultaneously
completionMode: Indexed # each pod gets a unique index (0, 1, 2, 3)
backoffLimit: 1
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
containers:
- name: playwright
image: your-registry.io/playwright-tests:latest
command:
- sh
- -c
- |
# JOB_COMPLETION_INDEX is 0-based, Playwright shards are 1-based
SHARD=$((JOB_COMPLETION_INDEX + 1))
echo "Running shard $SHARD/4"
npx playwright test --shard=$SHARD/4 --reporter=blob
resources:
requests:
memory: "1Gi"
cpu: "1000m"
limits:
memory: "2Gi"
cpu: "2000m"
envFrom:
- configMapRef:
name: playwright-config
env:
- name: TEST_USER_EMAIL
valueFrom:
secretKeyRef:
name: playwright-credentials
key: TEST_USER_EMAIL
- name: TEST_USER_PASSWORD
valueFrom:
secretKeyRef:
name: playwright-credentials
key: TEST_USER_PASSWORD
volumeMounts:
- name: reports
mountPath: /app/blob-reports
volumes:
- name: reports
persistentVolumeClaim:
claimName: playwright-reports
Run with a single command:
kubectl apply -f k8s/playwright-indexed-job.yaml -n testing
# Watch progress
kubectl get pods -n testing -l job-name=playwright-run -w
# Wait for completion
kubectl wait job/playwright-run \
--for=condition=complete \
--timeout=1800s \
-n testing
Step 9 — Scheduled Test Runs with CronJob
Run your full suite nightly automatically:
# k8s/playwright-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: playwright-nightly
namespace: testing
spec:
schedule: "0 1 * * *" # every night at 1:00 AM UTC
concurrencyPolicy: Forbid # don't overlap if previous run is still going
successfulJobsHistoryLimit: 7 # keep last 7 successful runs
failedJobsHistoryLimit: 3 # keep last 3 failed runs
jobTemplate:
spec:
completions: 4
parallelism: 4
completionMode: Indexed
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: playwright
image: your-registry.io/playwright-tests:latest
command:
- sh
- -c
- |
SHARD=$((JOB_COMPLETION_INDEX + 1))
npx playwright test \
--shard=$SHARD/4 \
--reporter=blob \
--project=chromium \
--project=firefox
resources:
requests:
memory: "1Gi"
cpu: "1000m"
limits:
memory: "2Gi"
cpu: "2000m"
envFrom:
- configMapRef:
name: playwright-config
env:
- name: TEST_USER_EMAIL
valueFrom:
secretKeyRef:
name: playwright-credentials
key: TEST_USER_EMAIL
- name: TEST_USER_PASSWORD
valueFrom:
secretKeyRef:
name: playwright-credentials
key: TEST_USER_PASSWORD
volumeMounts:
- name: reports
mountPath: /app/blob-reports
volumes:
- name: reports
persistentVolumeClaim:
claimName: playwright-reports
kubectl apply -f k8s/playwright-cronjob.yaml -n testing
# Trigger a manual run immediately (without waiting for schedule)
kubectl create job playwright-manual \
--from=cronjob/playwright-nightly \
-n testing
Step 10 — Viewing Logs and Debugging
# Watch all pods for a run
kubectl get pods -n testing -l app=playwright-shard -w
# Get logs from a specific shard pod
kubectl logs -n testing -l job-name=playwright-shard-1 --follow
# Get logs from all shard pods at once
kubectl logs -n testing -l app=playwright-shard --prefix=true
# Describe a pod to see events (useful for crash loops)
kubectl describe pod -n testing playwright-shard-1-xxxxx
# Shell into a running pod to debug
kubectl exec -it -n testing playwright-shard-1-xxxxx -- /bin/bash
# Check resource usage across pods
kubectl top pods -n testing -l app=playwright-shard
Common Issues and Fixes
| Symptom | Cause | Fix |
|---|---|---|
Pod stuck in Pending |
Insufficient cluster resources | Reduce CPU/memory requests or add nodes |
ImagePullBackOff |
Registry auth or wrong tag | Check imagePullSecrets, verify tag exists |
CrashLoopBackOff |
Test crash or missing env var | Check kubectl logs and kubectl describe pod |
PVC stuck in Pending |
No compatible storage class | Check kubectl get sc and update storageClassName |
| Tests timeout in init container | App not healthy | Check BASE_URL value and app health endpoint |
| All shards fail with auth error | Secret not found | Verify kubectl get secret playwright-credentials -n testing |
Complete File Structure
k8s/
├── playwright-configmap.yaml # non-secret configuration
├── playwright-pvc.yaml # shared report storage
├── playwright-shard-job.yaml # individual shard job template
├── playwright-indexed-job.yaml # indexed job (cleaner approach)
├── playwright-merge-job.yaml # report merge job
└── playwright-cronjob.yaml # scheduled nightly run
scripts/
└── run-playwright-k8s.sh # orchestration script
Dockerfile # multi-stage production image
.dockerignore
Putting It All Together
Here is the complete workflow from a single command:
# 1. Build and push the image
docker build -t your-registry.io/playwright-tests:$(git rev-parse --short HEAD) .
docker push your-registry.io/playwright-tests:$(git rev-parse --short HEAD)
# 2. Apply K8s resources (first time only)
kubectl apply -f k8s/playwright-configmap.yaml -n testing
kubectl apply -f k8s/playwright-pvc.yaml -n testing
# 3. Run the suite across 4 pods
IMAGE_TAG=$(git rev-parse --short HEAD) ./scripts/run-playwright-k8s.sh 4
# 4. View the merged HTML report
# Copy from the pod to local machine
kubectl cp testing/playwright-merge-xxxxx:/app/playwright-report ./playwright-report
npx playwright show-report ./playwright-report
Key Takeaways
- Multi-stage Docker build keeps the final image lean and secure
- Non-root user in the container is a security requirement, not optional
- Indexed Jobs are cleaner than individual per-shard Jobs for most cases
- CronJob handles scheduled runs with zero operational overhead
- Init containers prevent races where pods start before the app is ready
- Secrets vs ConfigMap — never put credentials in ConfigMaps; always use Secrets
- PVC with ReadWriteMany is the simplest shared storage for blob reports — use S3/GCS for production at scale
This setup scales to any test suite size. Need 20 pods? Change completions: 20 and parallelism: 20. The rest is handled by Kubernetes.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.