Introduction
Kubernetes has a reputation problem when it comes to local development. Most engineers first encounter it in production - a sprawling system of Deployments, Services, Ingress controllers, and Helm charts managed by a platform team - and reasonably conclude that running it on a laptop is overkill for building a CRUD API. That reputation is only half deserved. The complexity of Kubernetes scales with the complexity of what you ask it to do, and a minimal local setup for a Node.js service talking to PostgreSQL is genuinely small: a handful of YAML files and a local cluster tool that most engineers can have running in under fifteen minutes.
The case for doing this anyway is not aesthetic. Teams that deploy to Kubernetes in staging and production but develop locally with docker-compose or bare npm run dev accumulate a specific, recurring class of bug: things that work locally but fail once they hit a ConfigMap, a readiness probe, or a Kubernetes-specific DNS resolution rule. Closing that gap early, even in a stripped-down form, pays for itself the first time a "works on my machine" incident turns out to be a Kubernetes networking quirk that could have been caught days earlier. This walkthrough builds exactly that: a minimal, honest Kubernetes setup for local development of a Node.js service backed by PostgreSQL.
Why Run Kubernetes Locally At All
The most common objection to local Kubernetes is that docker-compose already solves the problem of "run my app and its database together". That's true for the narrow case of orchestrating two containers on one machine. It is not true once your production environment expresses configuration, secrets, networking, and scaling behavior in Kubernetes-native primitives that docker-compose has no equivalent for. A ConfigMap is not a .env file, even though it's often used to hold similar values - it has its own update semantics, mounting behavior, and interaction with pod restarts. A Kubernetes Service is not a Docker network alias - it involves kube-proxy, cluster DNS, and (depending on the CNI plugin) different failure modes than a bridge network. Developers who never touch these primitives locally end up debugging them for the first time under production pressure.
There's a second, quieter reason: onboarding. A new engineer who runs kubectl apply -f k8s/ and watches pods come up gets a mental model of the system's actual runtime shape - what talks to what, what's stateful, what's configuration versus code - that a docker-compose.yml abstracts away. This matters more as systems grow past a single service. Even in a minimal two-component setup like the one in this article, the Kubernetes manifests document the deployment topology in a way that's directly reusable in CI and staging, rather than being throwaway local-only tooling.
None of this is an argument for maximal Kubernetes adoption locally. Ingress controllers, service meshes, and autoscaling are genuinely unnecessary for inner-loop development and add friction without corresponding benefit. The goal here is the smallest slice of Kubernetes that still exercises the primitives you'll depend on elsewhere: Deployments, Services, ConfigMaps, Secrets, and persistent storage for the database.
Mental Model: Kubernetes as a Local Orchestration Layer, Not a Platform
It helps to think of this setup as a replacement for docker-compose, not a small production cluster. The unit of work is the same - "build my app's image, start it, start Postgres, wire them together" - but expressed in Kubernetes' declarative vocabulary instead of Compose's. A Deployment is roughly Compose's service: block plus restart policy. A Kubernetes Service is Compose's automatic DNS-by-service-name, made explicit. A PersistentVolumeClaim is Compose's named volume, but requesting storage from the cluster rather than the host filesystem directly.
Keeping this analogy in mind prevents over-engineering. When you find yourself reaching for a service mesh sidecar or a custom Ingress controller to solve a local development problem, it's worth asking whether you've drifted from "replicate the deployment topology" into "replicate the entire production platform" - the latter is rarely necessary and often actively slows down the local feedback loop developers rely on.
Prerequisites and Tooling Choices
A minimal local Kubernetes setup needs three things: a local cluster, a way to build and load container images into it, and a way to apply manifests. For the cluster, the two dominant choices are kind (Kubernetes IN Docker) and Minikube. Both run a real Kubernetes control plane locally; kind runs it inside Docker containers acting as cluster "nodes", which tends to be faster to start and tear down, while Minikube supports more driver backends (VM-based, Docker, and others) and has broader built-in addon support (dashboard, ingress, metrics-server). For a Node.js/Postgres inner-loop setup, kind's speed advantage usually wins.
For applying manifests, plain kubectl apply -f is sufficient at this scale and is the right starting point - resist the urge to introduce Helm or Kustomize for two services. Helm's templating and release management earns its complexity once you have multiple environments and shared charts across teams; for a single developer's local Postgres and API pod, it's ceremony without corresponding benefit. If your dev loop feels slow because you're manually rebuilding images and reapplying manifests on every code change, that's the moment to reach for Skaffold or Tilt, both of which watch your source tree, rebuild images, and redeploy automatically - but that's an optimization layer on top of the base setup, not a prerequisite for it.
You'll also need Docker (or a compatible container runtime) installed to build images, and kubectl configured to point at your local cluster's context. Verifying this before writing any manifests avoids a common source of confusion: kubectl silently applying resources to the wrong cluster context because a cloud provider's CLI plugin changed your default context during an unrelated gcloud or aws command.
Deep Technical Explanation: The Moving Parts
A minimal Node.js + PostgreSQL Kubernetes setup has five Kubernetes objects worth understanding individually before writing any YAML: a Deployment for the Node.js app, a Deployment (or StatefulSet) for PostgreSQL, a Service for each, a PersistentVolumeClaim for Postgres' data directory, and a ConfigMap/Secret pair for configuration and credentials.
The choice between a Deployment and a StatefulSet for Postgres deserves a moment of thought even in a local context. StatefulSets exist to give pods stable network identities and stable storage across rescheduling - properties that matter for multi-replica stateful systems like a Postgres cluster with replication, or Kafka brokers. For a single-replica local Postgres instance, a Deployment backed by a PersistentVolumeClaim behaves identically in practice, because there's no replica identity to preserve. Using a StatefulSet here isn't wrong, but it's added conceptual overhead that doesn't buy anything until you're running more than one Postgres pod - so this walkthrough uses a Deployment.
Networking is the part most developers underestimate. Inside the cluster, the Node.js pod reaches Postgres not via localhost or a Docker Compose service name, but via the Postgres Service's DNS name, which Kubernetes' internal DNS (CoreDNS) resolves automatically - typically postgres.<namespace>.svc.cluster.local, or just postgres within the same namespace. This is functionally similar to Compose's service-name DNS, but it's resolved by a different mechanism (CoreDNS plus kube-proxy's virtual IP routing rather than a Docker embedded DNS server), and debugging DNS failures inside a cluster requires different tools (kubectl exec into a pod and run nslookup, rather than inspecting a Docker network directly).
Persistence is the other piece that behaves differently from a naive Docker setup. A PersistentVolumeClaim requests storage matching a StorageClass; in a local kind or Minikube cluster, the default StorageClass typically provisions storage from the host machine's disk via a local-path provisioner, meaning data does survive pod restarts but not necessarily cluster deletion (kind delete cluster removes the underlying volumes along with everything else). This is worth stating explicitly to teammates, since "my local database keeps resetting" is a frequent point of confusion that traces back to this exact behavior.
Implementation Walkthrough
Start with the Node.js service itself. Assume a simple Express API with a single dependency on PostgreSQL via the pg library, reading its connection details from environment variables rather than a hardcoded connection string - this is what makes the same image portable between local Kubernetes, CI, and any real environment later.
// src/db.js
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.PGHOST || 'localhost',
port: Number(process.env.PGPORT) || 5432,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE,
max: 10,
idleTimeoutMillis: 30000,
});
pool.on('error', (err) => {
console.error('Unexpected PG pool error', err);
});
module.exports = { pool };
The Dockerfile should be a standard multi-stage build. Keeping the final image small and dependency-lean matters more locally than it might seem, since kind and Minikube both need to load or pull the image on every rebuild cycle, and a bloated image directly slows down your inner development loop.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app .
EXPOSE 3000
CMD ["node", "src/index.js"]
Next, the Postgres configuration and secrets. Non-sensitive values (database name, port) go in a ConfigMap; credentials go in a Secret, even locally, both because it establishes the right habit and because it keeps the manifests directly portable to a real cluster where Secrets may be backed by an external secrets manager.
# k8s/postgres-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-config
data:
POSTGRES_DB: appdb
PGDATA: /var/lib/postgresql/data/pgdata
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
type: Opaque
stringData:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: devpassword
The Postgres Deployment mounts a PersistentVolumeClaim and pulls its environment from that ConfigMap and Secret:
# k8s/postgres-deployment.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
envFrom:
- configMapRef:
name: postgres-config
- secretRef:
name: postgres-secret
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "appuser"]
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
Finally, the Node.js Deployment and Service, referencing the Postgres Service by its cluster DNS name:
# k8s/app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-app
spec:
replicas: 1
selector:
matchLabels:
app: node-app
template:
metadata:
labels:
app: node-app
spec:
containers:
- name: node-app
image: node-app:dev
imagePullPolicy: Never
ports:
- containerPort: 3000
env:
- name: PGHOST
value: postgres
- name: PGPORT
value: "5432"
- name: PGDATABASE
valueFrom:
configMapKeyRef:
name: postgres-config
key: POSTGRES_DB
- name: PGUSER
valueFrom:
secretKeyRef:
name: postgres-secret
key: POSTGRES_USER
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: POSTGRES_PASSWORD
readinessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 3
---
apiVersion: v1
kind: Service
metadata:
name: node-app
spec:
selector:
app: node-app
ports:
- port: 3000
targetPort: 3000
With imagePullPolicy: Never set, kind needs the image loaded directly rather than pulled from a registry: docker build -t node-app:dev . && kind load docker-image node-app:dev. After applying all manifests with kubectl apply -f k8s/, kubectl port-forward svc/node-app 3000:3000 exposes the service on localhost:3000 for browser or curl access, since Kubernetes Services are only routable from inside the cluster's network by default.
Trade-offs and Common Pitfalls
The most consequential trade-off in this setup is the deliberate omission of Ingress. Without it, reaching the service requires kubectl port-forward, which is a manual, per-terminal-session step that many developers find more friction than Compose's automatic port mapping. This is a reasonable cost to accept for a single service; it stops being reasonable once you're running several interdependent services locally and need stable, simultaneous access to each, at which point an Ingress controller (or a tool like Tilt, which automates port-forwarding) earns its keep.
A second, more subtle pitfall is state drift between the local Postgres schema and what migrations expect. Because the PersistentVolumeClaim survives pod restarts, a developer can end up running against a stale schema for days without noticing, especially after switching branches with different migration histories. Teams that hit this repeatedly tend to add an init container or a Job that runs migrations automatically on every deployment, rather than relying on developers to remember to run them manually - a pattern worth adopting even at this minimal scale, since forgetting a migration step is a solved problem, not a training issue.
Best Practices for Keeping This Sustainable
Treat the Kubernetes manifests as versioned source code, not throwaway local tooling. Keeping them in the same repository as the application, and ideally reusing the same base manifests (via Kustomize overlays, once you outgrow a single environment) between local, CI, and staging, is what makes this setup worth the initial investment. If the local manifests diverge from what actually runs in staging, you've recreated the exact "works locally, breaks in the real environment" gap this whole exercise was meant to close.
Resource requests and limits are easy to skip locally and easy to regret skipping. Even a token resources.requests block on both Deployments - say, 100m CPU and 128Mi memory for the app, 250m CPU and 256Mi memory for Postgres - surfaces scheduling and resource-pressure bugs on a developer's machine before they surface as an OOMKilled pod in production. It costs four lines of YAML and catches a category of incident that's otherwise invisible until it isn't.
Finally, resist scope creep. It's tempting to add a message queue, a Redis cache, or an Ingress controller to the local setup because "we'll need it eventually". Every additional component is another thing that can fail to start, another set of manifests to keep in sync, and another reason a new hire's first kubectl apply doesn't work cleanly. Add complexity to the local setup only when a real, current need forces it - not in anticipation of one.
Key Takeaways
- Use
kindfor the local cluster unless you specifically need Minikube's addon ecosystem or driver flexibility. - Skip Helm and Kustomize at this scale; plain
kubectl apply -fis the right amount of tooling for two services. - Model Postgres as a single-replica Deployment with a PersistentVolumeClaim, not a StatefulSet, until you actually run multiple replicas.
- Always read database credentials from a Secret and non-sensitive config from a ConfigMap, even locally - it's the habit that keeps manifests portable.
- Automate migrations with an init container or Job rather than relying on developers to remember to run them after pulling new code.
The 80/20 Insight
Nearly all of the practical value in this setup comes from just two Kubernetes concepts: Services (for DNS-based service discovery) and the ConfigMap/Secret split (for externalized configuration). Everything else - probes, resource limits, Ingress, StatefulSets - is refinement on top of a system that already works once those two pieces are in place. A developer who understands that a Service gives their app a stable DNS name to reach Postgres, and that configuration should never be hardcoded into an image, has internalized most of what makes Kubernetes-based local development different from docker-compose.
This is worth remembering when the setup starts to feel like it's sprawling. If a proposed addition doesn't touch service discovery or configuration management, it's very likely an optimization rather than a necessity - useful, perhaps, but not part of the core 20% doing 80% of the work.
Conclusion
A minimal Kubernetes setup for local Node.js and PostgreSQL development is not a compromise or a toy version of "real" Kubernetes - it's the same primitives, used at the smallest scale that still teaches them honestly. Five manifests and a local cluster tool are enough to give a development team the same mental model of service discovery, configuration, and persistence that they'll rely on in staging and production, without the operational weight of a full platform.
The discipline this exercise enforces - externalized config, DNS-based service references, explicit resource and storage boundaries - is valuable independent of whether Kubernetes remains the deployment target forever. Engineers who build this habit locally write services that are easier to run anywhere, because "anywhere" was never a hidden assumption baked into localhost connection strings in the first place.