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.
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
- Build shop-data and shop-web as above and log in through Adminer.
- From a pod in shop-web, resolve both
postgres(fails) andpostgres.shop-data(works) withnslookup, and explain why. - Delete
postgres-0and watch it come back with the same PVC and data (create a table first in Adminer). - Delete the Secret in shop-web and restart Adminer: what error does the pod show?
- Apply the NetworkPolicy (on a cluster with an enforcing CNI) and confirm a pod in
defaultcan'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+podSelectorlimits 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.