By this point you have myapp itself running in the dev cluster — your FastAPI HTTP API on port 8080, restarted by Tilt every time you save a file (for how that works, see chapter 8). But a single service in a vacuum doesn't mean much: myapp depends on PostgreSQL, and in a real project that almost always comes with a cache (Redis) and a message broker (RabbitMQ). In this chapter you'll learn how to bring up these dependencies so that your local setup stays close to production instead of turning into a zoo of different ways to start things.

Dependencies as part of the cluster, not docker-compose on the side

The most common temptation for a newcomer is to run the application in Kubernetes but spin up the database and Redis next to it with a plain docker compose up. Does it work? Yes. But you immediately end up with two parallel descriptions of your infrastructure: one world lives in compose.yaml, the other in Helm charts and k8s manifests. Why compose is not structurally equivalent to k8s manifests, and exactly what gets lost in the move, is covered in detail in chapter 2; here all that matters is the consequence for dependencies. In production there will be only the second world, which means everything you debugged in compose for the database and broker (service addresses, environment variables, startup order) will have to be rewritten from scratch for Kubernetes — and that's exactly where you'll make mistakes.

The idea of this article is environment parity: your local setup should be structurally the same as production. This is a direct continuation of the principle from the Twelve-Factor App (#10 Dev/prod parity) methodology. If you bring up Postgres/Redis/RabbitMQ inside the same cluster and with the same charts that will go to staging, you get unified DNS and service discovery for free (the application finds the database by the service name postgres, not by localhost:5432), shared Secrets and ConfigMaps, and you also catch manifest problems — RBAC, resource limits, readiness/liveness probes, network policies — before the rollout, not in production on a Friday evening.

Let's be honest: compose has weighty pluses too. The barrier to entry is lower, iteration is faster, you don't need to know Helm and k8s, and reproducibility comes from a single command. A significant part of the industry quite deliberately keeps compose for local development, leaving Kubernetes only for staging and production. That's a normal, workable choice.

The heuristic is simple. If your service is going to Kubernetes — and this whole article is precisely about preparing myapp for such a deployment — then bringing up dependencies inside the cluster is justified for the sake of parity: you figure out how it works once and then don't have to relearn it. But if the project isn't going to k8s, compose is the more rational choice and there's nothing to argue about here.

Installing infrastructure with Helm (Postgres, Redis, RabbitMQ)

Stateful dependencies (the ones that store data) are almost never written by hand — you take a ready-made Helm chart. Helm is the package manager for Kubernetes: a chart bundles a set of manifests with parameters that you override to fit your needs. A single helm install and Postgres, with all its Service, StatefulSet, Secret, and PVC, is already in the cluster.

The Bitnami warning, without which this chapter would be harmful

Historically the default was Bitnami charts and images (bitnami/postgresql, bitnami/redis, bitnami/rabbitmq) — they were recommended everywhere. But in 2025 the rules changed: on August 28, 2025 brownouts of the public images began, and from September 29, 2025 most public Bitnami OCI charts and images moved behind the commercial Broadcom subscription (Bitnami Secure Images). Only a limited set remained public, and only with the -latest tag; everything that was moved sits in the bitnamilegacy repository as unsupported legacy — without security patches. In other words, blindly recommending bitnami/* in 2026 is no longer acceptable (guide to migrating from Bitnami, Chainguard).

What to do in practice:

  • Chainguard has released 40+ Helm charts as a drop-in replacement for Bitnami (including PostgreSQL, Redis, RabbitMQ, and the RabbitMQ Cluster Operator) while keeping the familiar configuration — this is the most direct migration path.
  • You can take the vendors' official charts (for example, the operator from the RabbitMQ team).
  • You can deliberately pin to a specific Bitnami legacy tag — but only with the understanding that there will be no more security patches there. For a local setup this is a lesser evil than for production, but it's better not to make a habit of it.

Never bake passwords into chart values that end up in git. Charts create a Secret for credentials automatically, and if you set the password yourself, do it via --set locally or through a separate values file outside the repo. More on secrets in chapter 10.

The most reliable way to bring up PostgreSQL for dev isn't even Helm — it's a "raw" manifest of three objects: a Deployment (a single pod with the official postgres image), a Service (a stable DNS name postgres inside the namespace), and a PersistentVolumeClaim (a disk for the data). There's nothing to look up in chart repositories, no dependency on subscriptions or legacy images — it just works on any cluster. Save this in postgres.yaml:

1# postgres.yaml — minimal PostgreSQL for dev (Deployment + Service + PVC)
2apiVersion: v1
3kind: PersistentVolumeClaim
4metadata:
5  name: postgres-data
6  namespace: myapp
7spec:
8  accessModes: ["ReadWriteOnce"]
9  resources:
10    requests:
11      storage: 1Gi
12---
13apiVersion: apps/v1
14kind: Deployment
15metadata:
16  name: postgres
17  namespace: myapp
18spec:
19  replicas: 1
20  selector:
21    matchLabels: { app: postgres }
22  template:
23    metadata:
24      labels: { app: postgres }
25    spec:
26      containers:
27        - name: postgres
28          image: postgres:17        # official image from Docker Hub
29          ports:
30            - containerPort: 5432
31          env:
32            - name: POSTGRES_DB
33              value: myapp
34            - name: POSTGRES_USER
35              value: myapp
36            - name: POSTGRES_PASSWORD
37              value: devpass         # for dev; in prod — from a Secret (see chapter 10)
38            - name: PGDATA
39              value: /var/lib/postgresql/data/pgdata
40          volumeMounts:
41            - name: data
42              mountPath: /var/lib/postgresql/data
43          readinessProbe:
44            exec:
45              command: ["pg_isready", "-U", "myapp", "-d", "myapp"]
46            initialDelaySeconds: 5
47            periodSeconds: 5
48      volumes:
49        - name: data
50          persistentVolumeClaim:
51            claimName: postgres-data
52---
53apiVersion: v1
54kind: Service
55metadata:
56  name: postgres
57  namespace: myapp
58spec:
59  selector: { app: postgres }
60  ports:
61    - port: 5432
62      targetPort: 5432

It's applied with a single command:

1kubectl apply -n myapp -f postgres.yaml

After this, myapp finds the database by the DNS name postgres inside its own namespace (a connection string like postgresql://myapp:devpass@postgres:5432/myapp), and the data survives a pod restart thanks to the PVC (for how the PVC behaves when the cluster is recreated, see 9.4). This is the baseline path that you'll use throughout the rest of the chapter.

Alternative — a Helm chart. If you'd rather not pile up YAML and instead take a ready-made package with replication settings, metrics, and backups, you install PostgreSQL with a chart. Given the Bitnami brownout (see above), the command looks like this — with an explicit note about legacy images:

1# WARNING: since 2025-09-29 public bitnami images moved behind a subscription.
2# The public OCI chart is still available, but the default image must be switched
3# to the legacy repository (without security patches) — acceptable for dev.
4helm install postgres \
5  oci://registry-1.docker.io/bitnamicharts/postgresql \
6  --namespace myapp --create-namespace \
7  --set image.repository=bitnamilegacy/postgresql \
8  --set global.security.allowInsecureImages=true \
9  --set auth.username=myapp \
10  --set auth.password=devpass \
11  --set auth.database=myapp

For production it's better to move to a supported source — Chainguard's drop-in charts or the CloudNativePG operator (which is also handy when you need automatic failover and backups). For a local setup, though, the "raw" manifest above remains the simplest and most predictable choice.

In real work you won't run kubectl apply or helm install by hand — you'll hand it off to Tilt, so that dependencies come up and get torn down together with the rest of the project.

Wiring Helm charts into Tilt (helm() in the Tiltfile)

Tilt has three ways to work with Helm, and the difference between them is a frequent source of foot-guns.

  1. The built-in helm() function is essentially helm template: Tilt renders the chart to YAML locally, without touching the cluster. It works offline and fast, but it skips Helm hooks (more on this below in 9.5). It's suitable for your own chart, where there are no hooks.
  2. The helm_resource extension is a real helm install: with hooks, with dependencies. It's recommended for third-party charts like Postgres/Redis/RabbitMQ.
  3. helm_remote downloads a remote chart and again runs it through helm(), i.e. also without hooks.

The signature of the built-in function is helm(pathToChartDir, name='', namespace='', values=[], set=[], kube_version='', skip_crds=False); it returns a Blob with YAML that you feed into k8s_yaml() (Installing YAML with Helm, Tilt docs; Tiltfile API Reference). This is how we render our own myapp chart:

1# Tiltfile — our own chart via helm() (it has no hooks)
2k8s_yaml(helm(
3    './charts/myapp',
4    name='myapp',
5    namespace='myapp',
6    values=['./dev-values.yaml'],
7    set=['replicaCount=1'],
8))

For our PostgreSQL from the section above the simplest thing is to feed that same postgres.yaml into k8s_yaml() — Tilt will bring it up and tear it down together with the project. And we set the startup order via resource_deps, so that myapp waits for the database to be ready:

1# Tiltfile — our DB from the raw manifest, registered in Tilt
2k8s_yaml('postgres.yaml')
3
4# port_forward, to connect to the database from your IDE or psql on localhost:5432
5k8s_resource('postgres', port_forwards=['5432:5432'])
6
7# myapp starts only after postgres has come up
8k8s_resource('myapp', resource_deps=['postgres'])

The resource_deps parameter sets the order: Tilt won't start myapp until the postgres pod passes its readiness probe (pg_isready from the manifest). port_forwards forwards the port to localhost so that you can connect to the database from your IDE or psql.

If you took the alternative Helm-chart path, you register the dependency via helm_resource from the Tilt extensions. You load the functions, register the repository via helm_repo, and hang the chart with resource_deps on that repository (helm_resource README, tilt-extensions). For a Bitnami OCI chart you don't need to register a repository — you point chart directly at oci://...:

1# Tiltfile — alternative: PostgreSQL from a Helm chart via helm_resource
2load('ext://helm_resource', 'helm_resource')
3
4helm_resource(
5    'postgres',
6    'oci://registry-1.docker.io/bitnamicharts/postgresql',
7    namespace='myapp',
8    flags=[
9        '--set=image.repository=bitnamilegacy/postgresql',
10        '--set=global.security.allowInsecureImages=true',
11        '--set=auth.username=myapp',
12        '--set=auth.password=devpass',
13        '--set=auth.database=myapp',
14    ],
15    port_forwards=['5432:5432'],
16)
17
18k8s_resource('myapp', resource_deps=['postgres'])

Under the hood helm_resource does a real helm install (with hooks — unlike the built-in helm(), see below in 9.5), which is exactly why it's recommended for third-party charts with initialization. If you need to inject Tilt-built images into a chart (for example, your own worker), helm_resource has the image_deps + image_keys parameters for that. Redis and RabbitMQ are set up in exactly the same way — as a separate manifest or as their own chart from a supported source.

One pitfall for the built-in helm(): if your chart pulls in subchart dependencies, helm dependency update must be run outside Tilt, and the charts/ and tmpcharts/ directories must be added to .tiltignore, otherwise Tilt will catch their changes and go into an infinite restart loop:

1helm dependency update ./charts/myapp
1# .tiltignore
2charts/myapp/charts/
3charts/myapp/tmpcharts/

PersistentVolume and data in the local cluster

Where does Postgres data physically live? In Kubernetes a pod requests storage via a PersistentVolumeClaim (PVC) — a request for a disk of a certain size. Who satisfies that request is determined by the StorageClass. In k3d/k3s, local-path-provisioner works out of the box: the StorageClass is called local-path, the provisioner is rancher.io/local-path, the binding mode is WaitForFirstConsumer (the volume is created when a pod appears), and the reclaimPolicy is Delete.

To check that the class is in place:

1kubectl get storageclass
2# NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE
3# local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer

And now the main surprise for a newcomer: by default the data is ephemeral. local-path-provisioner writes to /var/lib/rancher/k3s/storage, but this path lives inside the container filesystem of the k3d node. Delete the cluster with k3d cluster delete or recreate it — and the database starts from a clean slate. Often this is even desirable: every start gives you a clean seed, and you don't have to clear out "stale" data by hand. But if you want the data to survive cluster recreation, you need to map a host directory when creating the cluster:

1# DB data survives cluster recreation: we map a host directory into all nodes
2k3d cluster create dev \
3  --volume $HOME/k3d-storage:/var/lib/rancher/k3s/storage@all

The @all suffix spreads the mapping across all nodes. Now PVCs will write to $HOME/k3d-storage on your machine, and k3d cluster delete won't touch the data. (With kind, the alternative local cluster, the same idea is implemented via extraMounts.) It's also worth remembering node affinity: a local-path PVC is bound to a specific node by kubernetes.io/hostname, so a pod with that volume will always be scheduled to the same place.

DB migrations and seeding at startup

Bringing up an empty Postgres isn't enough — myapp expects a ready schema. There are three patterns.

Helm hooks. A chart can contain a Job (a one-off task) annotated with "helm.sh/hook": pre-install,pre-upgrade — Helm will run it before installing/upgrading the release. The order of multiple hooks is set by helm.sh/hook-weight (ascending), and cleanup by helm.sh/hook-delete-policy (Chart Hooks, Helm docs):

1# templates/migrate-job.yaml — migrations as a Helm pre-install/pre-upgrade hook
2apiVersion: batch/v1
3kind: Job
4metadata:
5  name: myapp-migrate
6  annotations:
7    "helm.sh/hook": pre-install,pre-upgrade
8    "helm.sh/hook-weight": "-5"
9    "helm.sh/hook-delete-policy": hook-succeeded
10spec:
11  template:
12    spec:
13      restartPolicy: Never
14      containers:
15        - name: migrate
16          image: k3d-registry.localhost:5000/myapp:dev
17          command: ["alembic", "upgrade", "head"]

A critical nuance specifically for Tilt: the built-in helm() skips hooks, so such migrations will only fire if you bring up the chart via helm_resource (a real helm install). This is yet another argument in favor of helm_resource for anything that has initialization.

A separate Job + initContainer wait (the preferred pattern). Migrations live in a standalone Job, and the application pod waits via an initContainer until the database and schema are ready. In Tilt the order is built with resource_deps: postgres → migrate → seed → myapp.

1# Tiltfile — the initialization chain
2k8s_yaml(['migrate-job.yaml', 'seed-job.yaml'])
3k8s_resource('myapp-migrate', resource_deps=['postgres'])
4k8s_resource('myapp-seed',    resource_deps=['myapp-migrate'])
5k8s_resource('myapp',         resource_deps=['myapp-seed', 'redis', 'rabbitmq'])

Seeding (filling with test data) for dev is done with the same kind of separate Job after the migrations — that way it's easy to disable in production by simply not wiring up the resource.

Migrations right inside the application's initContainer — an antipattern

It's tempting to put alembic upgrade head in the initContainer of myapp itself, but with multiple replicas each pod will start running the migration, races will arise, and a long migration risks being killed by the readiness/liveness probe before it finishes. For a local setup with a single replica this is survivable, but it's better to develop the right habit from the start — a separate Job.

When to mock a production managed service and when to run it as-is

Not everything that exists in production needs to be dragged into your local setup literally. The boundary runs along whether the service is open-source or a proprietary managed one.

OSS services that already run in production — Postgres, Redis, RabbitMQ, Kafka, MinIO — we bring up as-is, right in the cluster. It's exactly the same software as in production, parity comes almost for free, and that's precisely what we've been doing all chapter.

Proprietary managed services — AWS S3/SQS/DynamoDB/Lambda, GCP Pub/Sub, and the like — can't be reproduced locally "as-is"; they don't exist as a runnable image. Here there are two paths: mock with an emulator, or reach into a real dev account. For AWS, LocalStack is popular — it emulates S3, SQS, DynamoDB, Lambda, RDS, and dozens of other services and can run in the same k8s cluster (via its own Operator or Helm). The pluses are obvious: a fast inner loop, zero cloud cost, isolation, offline operation, and code that often ports to the real AWS without changes.

An emulator is not 1:1 with the real cloud

The semantics of IAM, the consistency model, the limits, and rare edge cases diverge between LocalStack and the real AWS. A green local run is no guarantee that everything will work in the cloud.

Hence the working heuristic:

  • OSS dependencies (myapp → Postgres/Redis/RabbitMQ) — always bring them up as-is in the cluster.
  • Managed services for the fast loop, offline work, and simple operations — mock with an emulator.
  • Critical integrations with subtle semantics (IAM policies, specific features, behavior under load before production) — verify them against a real managed dev account, not just against a mock.

For myapp the question is easily settled

The database is OSS, so the whole arsenal from this chapter (a Helm chart in the cluster, a volume for the data, a Job for migrations and seeding) is the right path. How to feed the addresses and passwords of all these dependencies into the service without hardcoding is in the next chapter.

Sources

Want production-like dependencies in your local cluster?
Need help running Postgres, Redis or RabbitMQ inside your local Kubernetes cluster with Helm and Tilt? I can help you set up dependencies, persistent volumes, and migrations that mirror production.