How to Deploy a Stateless Application on Kubernetes

How to Deploy a Stateless Application on Kubernetes

Stateless applications are the bread and butter of Kubernetes — no persistent identity, no local data that needs to survive a restart, any replica can serve any request. That simplicity is exactly why Kubernetes handles them so gracefully, and why Deployment is the object built specifically for this pattern. In this guide I’ll walk through deploying a typical stateless web application end to end: Deployment, Service, ConfigMap, Secrets, health checks, autoscaling, and Ingress, tying together the full production picture.

What Makes an Application Stateless

A stateless app doesn’t store anything locally that it can’t afford to lose on restart — no session data written to local disk, no in-memory state that other replicas don’t share. Any state it needs lives externally: a database, a cache like Redis, object storage. This is what allows Kubernetes to freely kill, reschedule, and scale replicas without any coordination logic, which is precisely why Deployment (rather than StatefulSet) is the right tool.

Step 1: The Deployment

# myapp-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: myrepo/myapp:1.0.0
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: myapp-config
            - secretRef:
                name: myapp-secrets
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi

Step 2: Configuration via ConfigMap

Externalize non-sensitive config rather than baking it into the image:

apiVersion: v1
kind: ConfigMap
metadata:
  name: myapp-config
data:
  LOG_LEVEL: "info"
  CACHE_TTL_SECONDS: "300"
  FEATURE_FLAG_NEW_UI: "true"
kubectl apply -f myapp-config.yaml

Step 3: Sensitive Config via Secrets

kubectl create secret generic myapp-secrets \
  --from-literal=DATABASE_URL='postgres://user:pass@db-host:5432/mydb' \
  --from-literal=API_KEY='sk-abc123'

Or declaratively (base64-encoded, though for real production use, integrate a secrets manager instead of storing raw base64 in git):

apiVersion: v1
kind: Secret
metadata:
  name: myapp-secrets
type: Opaque
stringData:
  DATABASE_URL: postgres://user:pass@db-host:5432/mydb
  API_KEY: sk-abc123
kubectl apply -f myapp-deployment.yaml
kubectl rollout status deployment/myapp

Step 4: The Service

apiVersion: v1
kind: Service
metadata:
  name: myapp-service
spec:
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080
kubectl apply -f myapp-service.yaml
kubectl get endpoints myapp-service
NAME             ENDPOINTS                                     AGE
myapp-service    10.244.1.5:8080,10.244.1.6:8080,10.244.2.3:8080  30s

Three endpoints, one per replica, confirms the Service is correctly load-balancing across all Pods.

Step 5: Exposing Externally with Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - myapp.example.com
      secretName: myapp-tls
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp-service
                port:
                  number: 80
kubectl apply -f myapp-ingress.yaml
curl https://myapp.example.com/healthz

Step 6: Horizontal Pod Autoscaling

Since stateless apps scale horizontally so cleanly, add an HPA immediately for production readiness:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
kubectl apply -f myapp-hpa.yaml

Step 7: Zero-Downtime Rolling Updates

Since any Pod can handle any request, updates should always roll smoothly with no coordination:

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
kubectl set image deployment/myapp myapp=myrepo/myapp:1.1.0
kubectl rollout status deployment/myapp

maxUnavailable: 0 guarantees full capacity throughout the rollout, which is easy to afford for stateless apps precisely because there’s no per-replica state to preserve during replacement.

Step 8: Availability Protection

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: myapp-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: myapp
kubectl apply -f myapp-pdb.yaml

Step 9: Anti-Affinity for Real Resilience

Even with 3 replicas, if they all land on the same node, a single node failure takes the whole app down. Spread them explicitly:

spec:
  template:
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app
                      operator: In
                      values: ["myapp"]
                topologyKey: kubernetes.io/hostname

Or, for guaranteed spread rather than a soft preference, use topologySpreadConstraints:

spec:
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: myapp

Step 10: CI/CD Integration

kubectl set image deployment/myapp myapp=myrepo/myapp:$CI_COMMIT_SHA
kubectl rollout status deployment/myapp --timeout=180s || kubectl rollout undo deployment/myapp

Because the app is stateless, this pipeline needs no coordination logic beyond waiting for the rollout to succeed and rolling back automatically on failure — one of the biggest practical advantages of statelessness.

Debugging Common Issues

kubectl get pods -l app=myapp
kubectl describe pod <pod-name>
kubectl logs -l app=myapp --tail=100
kubectl get hpa myapp-hpa

If Pods keep restarting, check that livenessProbe thresholds aren’t too aggressive relative to real startup time — a probe firing before the app has actually finished initializing is a very common self-inflicted crash loop.

Best Practices

Common Mistakes

Twelve-Factor Alignment

Stateless applications on Kubernetes map almost directly onto the twelve-factor app methodology, and I’ve found leaning into that alignment deliberately makes a lot of Kubernetes-specific decisions obvious rather than arbitrary. Configuration comes from the environment (ConfigMaps/Secrets, not files baked into the image), the app treats logs as an event stream written to stdout rather than local files, and processes are disposable — they start fast and shut down gracefully on SIGTERM, which is exactly what makes rolling updates, autoscaling, and rescheduling all safe operations rather than risky ones. Any place your application deviates from this — writing to local disk, holding in-memory state another replica can’t see, assuming a slow, stateful startup sequence — is a place where Kubernetes’ assumptions about your workload and your application’s actual behavior part ways, usually surfacing as a subtle bug during a scale-down or rolling update rather than an obvious one.

Multi-Environment Configuration Strategy

A single container image should move unchanged from dev through staging to production — what differs between environments is configuration, not code. In practice this means layering environment-specific ConfigMap/Secret values (often via Helm’s values-dev.yaml, values-staging.yaml, values-prod.yaml pattern, or Kustomize overlays) rather than maintaining separate Dockerfiles or branches per environment:

# base/kustomization.yaml
resources:
  - deployment.yaml
  - service.yaml
  - configmap.yaml
# overlays/production/kustomization.yaml
resources:
  - ../../base
patches:
  - path: replica-patch.yaml
configMapGenerator:
  - name: myapp-config
    behavior: merge
    literals:
      - LOG_LEVEL=warn
      - CACHE_TTL_SECONDS=600
kubectl apply -k overlays/production

This Kustomize-based approach (built into kubectl directly, no extra tooling needed) keeps the base manifests identical across environments while layering only the differences on top — the same discipline Helm’s values-file layering achieves through a different mechanism.

Graceful Startup for Slow-Initializing Apps

Not every stateless app starts instantly — JVM-based services with class loading and JIT warmup, or apps that need to warm a local cache before serving traffic accurately, are common examples. Rather than tuning initialDelaySeconds to guess at startup time (which either wastes time on fast starts or fails on slow ones), a dedicated startupProbe handles this properly:

startupProbe:
  httpGet:
    path: /healthz
    port: 8080
  failureThreshold: 30
  periodSeconds: 10
readinessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 5
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 20

While startupProbe is running (up to failureThreshold * periodSeconds, 300 seconds here), the livenessProbe is disabled entirely — meaning a slow-starting app won’t get killed for taking longer than expected to become ready, while readinessProbe still gates it out of Service traffic until it’s genuinely serving correctly.

Graceful Degradation Under Dependency Failure

A stateless app’s health check shouldn’t blindly report unhealthy the instant a downstream dependency has a blip — that can trigger a cascading restart storm across every replica simultaneously, precisely when the system is already stressed. I generally split health into two endpoints with different semantics: a liveness check that only verifies the process itself is functioning (not its dependencies), and a readiness check that can reasonably reflect dependency health, since removing a Pod from load-balancing rotation is a much less disruptive response to a downstream blip than killing and restarting it outright.

# Simplified example
@app.route("/livez")
def livez():
    return "ok", 200  # process is alive, nothing more

@app.route("/readyz")
def readyz():
    if not database.is_reachable():
        return "database unreachable", 503
    return "ok", 200

This distinction — liveness for “is the process itself broken” versus readiness for “can this replica currently do useful work” — is one of the more common things I see conflated into a single generic /health endpoint, and un-conflating it noticeably improves how gracefully a stateless service degrades under partial outages.

Summary

Deploying a stateless application on Kubernetes is the pattern the platform was built around: a Deployment for interchangeable replicas, a Service for stable access, ConfigMap/Secret for externalized configuration, and Ingress for external exposure — all wrapped in health checks, autoscaling, and anti-affinity for real resilience. Because no replica holds unique state, Kubernetes can freely scale, reschedule, and update these workloads with minimal operational complexity, which is exactly why statelessness is worth preserving wherever your architecture allows it.

References

Exit mobile version