Skip to content

Kubernetes — Production Manifest Templates#

The full set that makes a workload prod-ready. Security is not a separate file; it is woven into every manifest (non-root, default-deny, least-privilege). Placeholders use <UPPER_CASE>. This page is the embedded form of the neighboring files; the source files live in the same folder.

Files#

File Kind What it provides
deployment.yaml Deployment Probes, resources, non-root, topology spread
service.yaml Service ClusterIP, http + metrics port
hpa.yaml HorizontalPodAutoscaler CPU/memory targeted, oscillation-preventing behavior
ingress.yaml Ingress TLS, rate limit, security headers
networkpolicy.yaml NetworkPolicy Default-deny + explicit allow
pdb.yaml PodDisruptionBudget Minimum standing pods during voluntary disruption
serviceaccount-rbac.yaml SA + Role + RoleBinding Least-privilege API access

For depth: 05-Kubernetes/ and 08-Security/Kubernetes-Hardening.md.

1️⃣ deployment.yaml#

Probe triad (startup/liveness/readiness), resource request/limit, non-root + read-only root FS + dropped capabilities, and topology spread for HA.

# Production-grade Deployment template
# Replace the placeholders (<UPPER_CASE>) with your own values.
# This template includes the following best practices:
#  - resource requests/limits
#  - readiness/liveness/startup probes
#  - non-root, read-only root FS, dropped capabilities
#  - topology spread (for HA)
#  - PodDisruptionBudget example (in a separate file — pdb.yaml)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
  labels:
    app.kubernetes.io/name: <APP_NAME>
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: <SYSTEM_NAME>
    app.kubernetes.io/managed-by: argocd
spec:
  replicas: 3
  revisionHistoryLimit: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0           # 0 for zero-downtime
  selector:
    matchLabels:
      app.kubernetes.io/name: <APP_NAME>
  template:
    metadata:
      labels:
        app.kubernetes.io/name: <APP_NAME>
        app.kubernetes.io/component: api
      annotations:
        # Prometheus scrape (if you use it)
        prometheus.io/scrape: "true"
        prometheus.io/port: "9090"
        prometheus.io/path: "/metrics"
    spec:
      serviceAccountName: <APP_NAME>           # least-privilege sa
      automountServiceAccountToken: false       # turn off if not needed
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app.kubernetes.io/name: <APP_NAME>
                topologyKey: kubernetes.io/hostname
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: <APP_NAME>
      containers:
        - name: app
          image: <REGISTRY>/<IMAGE>:<TAG>      # SHA-pinned recommended (sha256:...)
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
              protocol: TCP
            - name: metrics
              containerPort: 9090
              protocol: TCP
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: APP_VERSION
              value: "<VERSION>"               # set by release-please/Argo Image Updater
          envFrom:
            - configMapRef:
                name: <APP_NAME>-config
            - secretRef:
                name: <APP_NAME>-secrets       # managed via External Secrets Operator
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: 1000m                        # CPU limit is optional — it throttles
              memory: 512Mi                     # prevents OOM
          startupProbe:
            httpGet:
              path: /health/live
              port: http
            failureThreshold: 30
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 0
            periodSeconds: 10
            failureThreshold: 3
            timeoutSeconds: 5
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 0
            periodSeconds: 5
            failureThreshold: 3
            timeoutSeconds: 3
          lifecycle:
            preStop:
              exec:
                # Graceful shutdown: once you tell the load balancer "I'm draining",
                # wait for existing requests to finish
                command: ["sh", "-c", "sleep 15"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache/app
      volumes:
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir:
            sizeLimit: 100Mi

2️⃣ service.yaml#

apiVersion: v1
kind: Service
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
  labels:
    app.kubernetes.io/name: <APP_NAME>
spec:
  type: ClusterIP
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP
    - name: metrics
      port: 9090
      targetPort: metrics
      protocol: TCP
  selector:
    app.kubernetes.io/name: <APP_NAME>

3️⃣ hpa.yaml#

# HorizontalPodAutoscaler — CPU/memory + custom metric
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: <APP_NAME>
  minReplicas: 3                  # at least 3 for HA
  maxReplicas: 30
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Percent
          value: 100              # can grow 100% (2x) in 60s
          periodSeconds: 60
        - type: Pods
          value: 4                # or add 4 pods
          periodSeconds: 60
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 300        # scale down slowly over 5min (prevent oscillation)
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
    # Custom metric example (Prometheus Adapter must be installed):
    # - type: Pods
    #   pods:
    #     metric:
    #       name: http_requests_per_second
    #     target:
    #       type: AverageValue
    #       averageValue: "100"

4️⃣ ingress.yaml#

# Ingress — TLS termination, rate limit, basic security headers
# (assumes NGINX Ingress Controller; separate template for Gateway API)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"

    # Rate limiting — 100 requests per IP within a minute
    nginx.ingress.kubernetes.io/limit-rps: "100"
    nginx.ingress.kubernetes.io/limit-connections: "20"

    # Body size limit
    nginx.ingress.kubernetes.io/proxy-body-size: "8m"

    # Timeouts
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "5"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "60"

    # Security headers
    nginx.ingress.kubernetes.io/configuration-snippet: |
      more_set_headers "X-Content-Type-Options: nosniff";
      more_set_headers "X-Frame-Options: DENY";
      more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
      more_set_headers "Permissions-Policy: geolocation=(), microphone=(), camera=()";
      more_set_headers "Strict-Transport-Security: max-age=31536000; includeSubDomains; preload";

spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - <DOMAIN>
      secretName: <APP_NAME>-tls
  rules:
    - host: <DOMAIN>
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: <APP_NAME>
                port:
                  number: 80

5️⃣ networkpolicy.yaml — default-deny#

First close everything, then open only what is needed. Egress to DNS is mandatory — don't forget it.

# NetworkPolicy — default-deny + explicit allow
# The CNI must support NetworkPolicy (Cilium, Calico, etc.)

# 1) DEFAULT DENY within the namespace (everything closed)
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: <NAMESPACE>
spec:
  podSelector: {}                 # all pods
  policyTypes:
    - Ingress
    - Egress

# 2) Allow egress to DNS (kube-dns) — MANDATORY
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: <NAMESPACE>
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

# 3) The application's specific allows
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: <APP_NAME>-allow
  namespace: <NAMESPACE>
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: <APP_NAME>
  policyTypes:
    - Ingress
    - Egress
  ingress:
    # Accept traffic from the ingress controller
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
      ports:
        - protocol: TCP
          port: 8080

    # Monitoring in the same namespace (prometheus scrape)
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
          podSelector:
            matchLabels:
              app.kubernetes.io/name: prometheus
      ports:
        - protocol: TCP
          port: 9090
  egress:
    # To Postgres in the same namespace
    - to:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: postgres
      ports:
        - protocol: TCP
          port: 5432

    # External API calls (HTTPS)
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8        # not open to internal networks
              - 172.16.0.0/12
              - 192.168.0.0/16
      ports:
        - protocol: TCP
          port: 443

6️⃣ pdb.yaml#

# PodDisruptionBudget — guarantee of the minimum number of pods staying up
# during voluntary disruption (node drain, etc.)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
spec:
  minAvailable: 2                  # or: maxUnavailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: <APP_NAME>
  unhealthyPodEvictionPolicy: AlwaysAllow   # K8s 1.27+ — unhealthy pods can be evicted

7️⃣ serviceaccount-rbac.yaml — least-privilege#

# Least-privilege ServiceAccount + Role + RoleBinding
# Used if your application talks to the Kubernetes API.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
automountServiceAccountToken: false   # we'll enable it separately in the podSpec

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
rules:
  # Only in its own namespace, only specific resources and verbs
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
  # Lease (for leader election)
  - apiGroups: ["coordination.k8s.io"]
    resources: ["leases"]
    verbs: ["get", "create", "update"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: <APP_NAME>
  namespace: <NAMESPACE>
subjects:
  - kind: ServiceAccount
    name: <APP_NAME>
    namespace: <NAMESPACE>
roleRef:
  kind: Role
  name: <APP_NAME>
  apiGroup: rbac.authorization.k8s.io

🚫 Anti-Pattern#

Anti-pattern Why it's bad Correct
No probes K8s sends traffic to a not-ready pod; won't restart a dead pod startup + liveness + readiness triad
No resource limit One pod starves the node; OOM hits its neighbors request + limit; memory limit as OOM boundary
No NetworkPolicy A compromised pod moves laterally across the whole cluster Default-deny + explicit allow
cluster-admin ServiceAccount Compromised token = the whole cluster Namespace-scoped Role, only the needed verbs
Root container Escape = node root runAsNonRoot + drop: ["ALL"] + read-only FS
No PDB Node drain takes down all replicas at once Baseline guarantee with minAvailable

📋 Checklist#

[ ] Deployment: request + limit set
[ ] Deployment: 3 probes (startup/liveness/readiness) defined
[ ] Pod non-root (runAsNonRoot, drop ALL, read-only root FS)
[ ] NetworkPolicy default-deny + DNS allow applied
[ ] ServiceAccount least-privilege (NO cluster-admin)
[ ] PDB minAvailable configured
[ ] Image SHA-pinned (NO `:latest`)

"Security is not a separate manifest — it's a field inside every manifest. It isn't added later, it's woven in from the start."