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
- Externalize all configuration via ConfigMaps and Secrets — never bake environment-specific values into the image.
- Set both readiness and liveness probes, with different, deliberate thresholds for each.
- Use anti-affinity or topology spread constraints so replicas don’t cluster on one node.
- Set
maxUnavailable: 0for rolling updates on customer-facing stateless services, since there’s no real cost to doing so. - Store all logs to stdout/stderr rather than local files, keeping the container genuinely disposable.
Common Mistakes
- Writing session data or uploaded files to local container disk, quietly turning a “stateless” app stateful and losing data on every restart or reschedule.
- Skipping anti-affinity, leaving all replicas vulnerable to a single node failure despite having “3 replicas for redundancy.”
- Hardcoding configuration instead of using ConfigMaps/Secrets, making the same image unusable across environments without rebuilding.
- Not setting resource requests, which breaks both scheduling quality and HPA accuracy.
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.