Kubernetes Administration — Level by Level›18 · Namespaces & an app with a database

Lesson 18 of 32 · Level 3 — Application delivery

Namespaces & an app with a database

Run an application that talks to a database across namespaces: a PostgreSQL StatefulSet with storage and a Secret in shop-data, a web app in shop-web connecting by cross-namespace DNS, an init container that waits for the database, and a NetworkPolicy so only the app can reach it.

Practitioner
Key wordsnamespacescross-namespace DNSStatefulSetheadless ServicePersistentVolumeClaimSecretinit containerNetworkPolicyPostgreSQLAdminer
Ingress shop.example.com / and /api namespace: shop-web Service frontend ClusterIP :80 Deployment frontend 3 pods Service api ClusterIP :8080 Deployment api 3 pods ConfigMap + Secret DB host, password / /api namespace: shop-data Service postgres postgres.shop-data .svc.cluster.local StatefulSet postgres postgres-0 PVC data-postgres-0 StorageClass cross-namespace DNS
The shop: web tiers in shop-web, PostgreSQL in shop-data, joined by cross-namespace DNS.

Why separate namespaces?

Namespaces group resources and are the unit for access (RBAC), quotas and network policy. A common split:

  • shop-web: stateless app tiers, deployed often by the app team.
  • shop-data: the database, changed rarely, owned more carefully.

Kubernetes DNS connects them: every Service is reachable as <service>.<namespace>.svc.cluster.local.

Namespaces are like different buildings on a school campus: the classrooms (web apps) in one, the library archive (database) in another. Anyone can find the archive by its full address ("Archive, Library Building"), but the archive door has a list of who may come in (NetworkPolicy), and each building keeps its own keys (Secrets).

Step 1: the database in shop-data

# db.yaml (namespace: shop-data)
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
stringData:
  POSTGRES_USER: shop
  POSTGRES_PASSWORD: change-me-in-real-life
  POSTGRES_DB: shop
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  clusterIP: None                  # headless: DNS points at the pod(s)
  selector: { app: postgres }
  ports: [ { name: pg, port: 5432 } ]
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels: { app: postgres }
  template:
    metadata:
      labels: { app: postgres }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports: [ { name: pg, containerPort: 5432 } ]
          envFrom: [ { secretRef: { name: db-credentials } } ]
          env:
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata   # subfolder avoids lost+found issues
          readinessProbe:
            exec: { command: [ "pg_isready", "-U", "shop" ] }
            periodSeconds: 5
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: [ ReadWriteOnce ]
        resources: { requests: { storage: 1Gi } }
$ kubectl create namespace shop-data
$ kubectl apply -n shop-data -f db.yaml
$ kubectl get pods,pvc -n shop-data
NAME             READY   STATUS    RESTARTS   AGE
pod/postgres-0   1/1     Running   0          40s
NAME                                    STATUS   VOLUME     CAPACITY   ACCESS MODES
persistentvolumeclaim/data-postgres-0   Bound    pvc-3f…    1Gi        RWO

The PVC data-postgres-0 follows postgres-0 wherever it's rescheduled (within what the storage allows). See lesson 07 for storage basics.

Step 2: the app in shop-web

Secrets are namespaced, so the app's namespace needs its own copy of the credentials (in real platforms, External Secrets syncs them from one source; see Kubernetes Security & Hardening, lesson 11):

$ kubectl create namespace shop-web
$ kubectl create secret generic db-credentials -n shop-web \
    --from-literal=POSTGRES_USER=shop --from-literal=POSTGRES_PASSWORD=change-me-in-real-life

As the "app" we use Adminer (a small database web UI) so you can see the connection working. The init container waits until the database answers:

# app.yaml (namespace: shop-web)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: adminer
spec:
  replicas: 1
  selector:
    matchLabels: { app: adminer }
  template:
    metadata:
      labels: { app: adminer, tier: api }
    spec:
      initContainers:
        - name: wait-for-db
          image: postgres:16
          command: [ "sh", "-c", "until pg_isready -h postgres.shop-data.svc.cluster.local -p 5432; do echo waiting; sleep 2; done" ]
      containers:
        - name: adminer
          image: adminer:4
          ports: [ { name: http, containerPort: 8080 } ]
          env:
            - name: ADMINER_DEFAULT_SERVER
              value: postgres.shop-data.svc.cluster.local
---
apiVersion: v1
kind: Service
metadata:
  name: adminer
spec:
  selector: { app: adminer }
  ports: [ { name: http, port: 80, targetPort: http } ]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: adminer
spec:
  ingressClassName: nginx
  rules:
    - host: adminer.127.0.0.1.nip.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: { name: adminer, port: { name: http } }
$ kubectl apply -n shop-web -f app.yaml
$ kubectl get pods -n shop-web -w          # watch Init:0/1 → Running

Open http://adminer.127.0.0.1.nip.io/, choose PostgreSQL, and log in with the credentials. A real app would read the Secret as environment variables (envFrom) and connect with the same hostname.

Step 3: only the app may reach the database

# netpol.yaml (namespace: shop-data)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: postgres-from-shop-web
spec:
  podSelector:
    matchLabels: { app: postgres }
  policyTypes: [ Ingress ]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: shop-web }
          podSelector:
            matchLabels: { tier: api }
      ports: [ { protocol: TCP, port: 5432 } ]

Both selectors in the same from entry means "pods labelled tier: api in shop-web". NetworkPolicies need a CNI that enforces them (Calico, Cilium, and others; check yours).

$ kubectl apply -n shop-data -f netpol.yaml
$ kubectl run probe --rm -it -n default --image=postgres:16 -- pg_isready -h postgres.shop-data -t 3
postgres.shop-data:5432 - no response        # blocked (if your CNI enforces policies)

Try it: two namespaces, one database

  1. Build shop-data and shop-web as above and log in through Adminer.
  2. From a pod in shop-web, resolve both postgres (fails) and postgres.shop-data (works) with nslookup, and explain why.
  3. Delete postgres-0 and watch it come back with the same PVC and data (create a table first in Adminer).
  4. Delete the Secret in shop-web and restart Adminer: what error does the pod show?
  5. Apply the NetworkPolicy (on a cluster with an enforcing CNI) and confirm a pod in default can't connect while Adminer still can.

Going deeper: databases on Kubernetes

  • For production, use an operator (e.g. CloudNativePG) for replication, failover, backups and upgrades (see Kubernetes Storage & Data Protection, lesson 06).
  • Keep credentials in a secret manager and rotate them; the app should reconnect gracefully.
  • Add ResourceQuotas and RBAC per namespace so teams can't step on each other (lesson 29).

Recap

  • Namespaces separate ownership, access, quotas and policy; DNS joins them: service.namespace.svc.cluster.local.
  • Databases: StatefulSet + headless Service + volumeClaimTemplates + Secret.
  • Secrets are namespaced; init containers wait for dependencies.
  • A NetworkPolicy with namespaceSelector + podSelector limits who can reach the database.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.