← Blog

"Playwright Series #11: Docker Setup and Running Tests in Kubernetes Pods"

Go beyond basic CI. Build a production Docker image for Playwright, run test shards across parallel Kubernetes pods, collect results with a merge Job, and manage secrets with ConfigMaps and environment injection.

reading now
views
comments

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: ReadWriteMany requires NFS, AWS EFS, Azure Files, or GCS Fuse. For a single-node cluster you can use ReadWriteOnce — 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.

0 / 1000