Deploy a Python App to Kubernetes with Pulumi

Getting a container from a registry into a serving cluster involves five objects that must agree on labels, ports, and secrets. This guide, part of managing Kubernetes with the Pulumi Python provider under Pulumi patterns and provider management, deploys a Python web service end to end and shows where the wiring usually breaks.

Context

A working deployment is a chain of agreements: the service's selector must match the pod's labels, the service's targetPort must match the container's port, and the ingress must name a service that exists. Any mismatch produces a healthy-looking cluster that serves nothing, because Kubernetes reports each object as fine individually. Building the chain in one typed program makes the agreements explicit rather than coincidental.

The agreement chain The agreement chain: Serving app with 4 facets. Serving app Labels selector must match Ports targetPort must match Secrets mounted, not baked Probes gate the rollout
Each link is checked by a different controller, so a mismatch fails silently rather than loudly.

Nothing in Kubernetes validates the chain end to end. The API server checks each object against its own schema, then hands it to a controller that reconciles only its own concern: the Deployment controller creates a ReplicaSet, the ReplicaSet controller creates pods, the endpoints controller watches pods whose labels match a service selector, and the ingress controller programs a load balancer for a service name. A selector typo produces four green objects and an empty endpoints list, because no controller is responsible for noticing that the fourth link points at nothing.

Objects this one Pulumi program creates, in dependency order Objects this one Pulumi program creates, in dependency order: layered from Namespace apps down to Service api. Namespace apps created first, deleted last Secret api-db stringData, referenced by secretKeyRef Deployment api auto-named api-<suffix>, owns the ReplicaSet Service api explicit metadata.name so DNS is predictable
Pulumi derives this ordering from the references between resources, not from the file order.

Declaring all of it in Python changes two things. The labels become a single value referenced by both the pod template and the service selector, so drift is impossible rather than merely unlikely. And the pulumi-kubernetes provider adds its own await logic on top of the API: it holds the resource in a creating state until the Deployment reports availableReplicas equal to the desired count, so a failing image or a probe that never passes turns into a failed pulumi up rather than a silent partial rollout.

Prerequisites

  • pulumi-kubernetes>=4.0 with a configured provider and a target namespace
  • A container image in a registry the target cluster can pull from, referenced by an immutable tag or digest
  • An image pull secret if the registry is private
  • Resource requests and limits agreed with whoever owns cluster capacity
  • A kubeconfig context that can create Deployments, Services and Secrets in that namespace
# CLI: confirm the Kubernetes cluster can pull the image before deploying
kubectl -n apps run pullcheck --image=ghcr.io/acme/api:1.4.2 --restart=Never --rm -it -- true

If that pod ends in ImagePullBackOff, stop here — the same failure inside a Deployment costs a ten-minute Pulumi timeout instead of ten seconds. Check the permissions on your kubeconfig context in the same way, since a create that is rejected by RBAC surfaces as Error from server (Forbidden) only after the program has already built its resource graph.

Implementation

What pulumi up does while creating the Deployment What pulumi up does while creating the Deployment: Pulumi CLI → k8s provider → API server → Deployment controller. Pulumi CLI k8s provider API server Deploymentcontroller create Deployment POST apps/v1 reconcile ReplicaSet status.availableReplicas watch event resolve outputs
The provider blocks on the watch stream until availableReplicas reaches the desired count.

Step 1 — build an explicit provider rather than inheriting ambient kubeconfig. A program that picks up whatever context happens to be current is one kubectl config use-context away from deploying to the wrong place.

# provider.py — pin the target explicitly
# CLI: pulumi config set k8sContext arn:aws:eks:eu-west-1:123456789012:cluster/prod
import pulumi
import pulumi_kubernetes as k8s

config = pulumi.Config()

k8s_provider = k8s.Provider(
    "target",
    context=config.require("k8sContext"),
    enable_server_side_apply=True,
)
# Provider note: server-side apply makes field ownership explicit, so a field
# managed by an autoscaler is not silently reverted on the next up.

namespace = k8s.core.v1.Namespace(
    "apps",
    metadata={"name": "apps"},
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)

Step 2 — define the shared labels once. Every mismatch bug starts with two hand-written label dictionaries that drifted apart.

Request path Request path: Ingress then Service then Endpoints then Pod Ingress host + path Service port 80 Endpoints matched pods Pod container :8080
Each hop matches on a different field; the endpoints list is where a selector mismatch becomes visible.
# app.py — one label source, used by both the deployment and the service
# CLI: pulumi preview
from dataclasses import dataclass

@dataclass(frozen=True)
class AppSpec:
    name: str
    image: str
    port: int = 8080
    replicas: int = 3

    @property
    def labels(self) -> dict[str, str]:
        return {"app": self.name, "managed-by": "pulumi"}

api = AppSpec(name="api", image="ghcr.io/acme/api:1.4.2")

Freezing the dataclass matters more than it looks. The label dictionary is read during resource construction and again when the service is built; a mutable spec that some later code path edits would produce two different selectors from what reads as one source of truth.

Step 3 — declare the deployment with probes and resource limits. The readiness probe is what makes pulumi up meaningful; without it the apply completes as soon as the pods are scheduled.

# deployment.py — the workload itself
# CLI: pulumi up
import pulumi
import pulumi_kubernetes as k8s

opts = pulumi.ResourceOptions(provider=k8s_provider)

deployment = k8s.apps.v1.Deployment(
    api.name,
    metadata={"namespace": "apps", "labels": api.labels},
    spec={
        "replicas": api.replicas,
        "selector": {"match_labels": api.labels},
        "template": {
            "metadata": {"labels": api.labels},
            "spec": {"containers": [{
                "name": api.name,
                "image": api.image,
                "ports": [{"container_port": api.port}],
                "env": [{
                    "name": "DATABASE_URL",
                    "value_from": {"secret_key_ref": {
                        "name": "api-db", "key": "url"}},
                }],
                "readiness_probe": {"http_get": {"path": "/healthz",
                                                  "port": api.port}},
                "resources": {
                    "requests": {"cpu": "100m", "memory": "128Mi"},
                    "limits": {"memory": "512Mi"},
                },
            }]},
        },
    },
    opts=opts,
)
# State implication: pulumi up blocks until replicas pass readiness, so a bad
# image or failing probe fails the deploy instead of leaving it half-rolled.

Two details in that block are easy to skim past. The Deployment has no metadata.name, so Pulumi auto-names it api-<random-suffix> — that is intentional, because it lets Pulumi create a replacement before deleting the old object when a change forces a replace. The Service in the next step does pin its name, because the in-cluster DNS record api.apps.svc.cluster.local is part of your contract with callers and must not carry a suffix.

The three probes and what each one actually controls The three probes and what each one actually controls: comparison across Failure effect, Blocks pulumi up, Typical path. Probe Failure effect Blocks pulumi up Typical path startup Restarts a slow-booting container Indirectly /healthz readiness Removes the pod from endpoints Yes /healthz liveness Kills and restarts the container No /livez
Only readiness gates the rollout, which is why a missing readiness probe makes the apply meaningless.

Step 4 — create the secret the container reads, and expose the app with a service that reuses the same labels object.

# service.py — selector and targetPort derived from the same spec
# CLI: pulumi up --stack prod
db_secret = k8s.core.v1.Secret(
    "api-db",
    metadata={"name": "api-db", "namespace": "apps"},
    string_data={"url": config.require_secret("databaseUrl")},
    opts=opts,
)
# State implication: the value stays sealed in the Pulumi checkpoint, but it is
# only base64-encoded inside the Kubernetes object itself.

service = k8s.core.v1.Service(
    api.name,
    metadata={"name": api.name, "namespace": "apps"},
    spec={
        "selector": api.labels,               # same dict as the pod template
        "ports": [{"port": 80, "target_port": api.port}],
        "type": "ClusterIP",
    },
    opts=pulumi.ResourceOptions(provider=k8s_provider, depends_on=[deployment]),
)

pulumi.export("serviceName", service.metadata.name)

The depends_on is not required for correctness — Kubernetes tolerates a service that matches nothing yet — but it makes the ordering in pulumi up match the order a human would debug in, so the deployment failure appears before the service is reported as created.

How should traffic reach this service? How should traffic reach this service?: choose among 4 options. Who calls the API? in-mesh ClusterIP service,DNS name only browsers Ingress in front ofClusterIP raw TCP LoadBalancer service just me kubectlport-forward, noobject
ClusterIP is the default worth defending: every other option adds an external dependency.

Verification

The single most useful check is the endpoints list — it proves the service selector actually matched pods.

# CLI: endpoints populated means selector and labels agree
kubectl -n apps get endpoints api -o jsonpath='{.subsets[*].addresses[*].ip}{"\n"}'
kubectl -n apps rollout status deployment/api --timeout=120s
kubectl -n apps run probe --rm -it --restart=Never --image=curlimages/curl -- \
  curl -s -o /dev/null -w '%{http_code}\n' http://api.apps.svc.cluster.local/healthz

Three signals, in the order they fail. An empty endpoints output with running pods is a selector or namespace mismatch. A rollout status that reports Waiting for deployment "api" rollout to finish: 2 of 3 updated replicas are available... and then times out is a readiness or capacity problem, and kubectl -n apps get events --sort-by=.lastTimestamp names which. A 000 from the curl probe with populated endpoints points at targetPort: traffic is reaching a port nothing listens on.

Cross-check against Pulumi's own view with pulumi stack output serviceName and pulumi refresh --diff. A refresh that reports changes immediately after a successful apply usually means a mutating admission webhook — a service mesh injector, for example — rewrote the pod template, and those fields should be added to ignore_changes.

Gotchas & Edge Cases

An empty endpoints list with healthy pods means the service selector does not match the pod labels. Deriving both from one object, as above, removes the failure mode entirely.

Memory limits without requests let the scheduler place a pod where it cannot grow, producing evictions under load. Always set requests; set memory limits and think hard before setting CPU limits, which throttle rather than kill.

Secrets as environment variables are readable by anything that can exec into the pod or read its spec. For sensitive values prefer a mounted volume, and keep the secret itself out of the program using the patterns in using Pulumi config and typed settings.

Changing a label forces a replacement. spec.selector is immutable on a Deployment, so editing AppSpec.labels makes the API server answer Deployment.apps "api-7f3a1b2c" is invalid: spec.selector: Invalid value: field is immutable. Pulumi handles this by replacing the object, which means a brief window with two ReplicaSets serving; plan label changes like a deploy, not like an edit.

A missing secret key stalls the pod, not the API call. The Deployment is created successfully and the pod enters CreateContainerConfigError with the event Error: secret "api-db" not found. Pulumi's await logic then fails the apply with 2 errors occurred: * Timeout occurred for 'api-7f3a1b2c' * [MinimumReplicasUnavailable] Deployment does not have minimum availability, which names the symptom rather than the cause — always read the pod events next.

An autoscaler and a declared replica count fight each other. If a HorizontalPodAutoscaler owns spec.replicas, every pulumi up resets it to the declared value and the autoscaler pushes it back. Add ignore_changes=["spec.replicas"] to the deployment's ResourceOptions and let the autoscaler own the field.

Operational Notes

Pin the image by digest in production. A tag is a mutable pointer, so ghcr.io/acme/api:1.4.2 today and the same string next week are not guaranteed to be the same bytes, and Kubernetes will not re-pull an image it already has cached under that tag unless imagePullPolicy says Always. ghcr.io/acme/api@sha256:… removes both ambiguities and makes the Pulumi diff on a release honest: the digest changes, so the pod template changes, so a rollout happens.

Tune the await window rather than disabling it. The provider's default timeout is generous but finite, and a workload with a slow startup — a JVM sidecar, a large model download — needs the pulumi.com/timeoutSeconds annotation on the Deployment rather than the blunt pulumi.com/skipAwait: "true", which turns every deploy into fire-and-forget and removes the main reason to run this through Pulumi at all. Details of the await machinery, including how it reads the Deployment's conditions, are in awaiting Kubernetes rollouts in Pulumi Python.

For the rollout itself, set strategy.rollingUpdate.maxUnavailable: 0 when the service must not lose capacity during a deploy, and pair it with a PodDisruptionBudget so node drains respect the same floor. Keep revisionHistoryLimit low — the default of ten keeps ten old ReplicaSets around, which clutters every kubectl get rs you will run while debugging.

Finally, split the program the moment a second workload appears. One stack per environment with a component resource per application scales better than one file that grows a section per service, and it keeps the blast radius of a bad apply to a single application. If several applications share a chart or a base manifest, deploying Helm charts with the Pulumi Kubernetes provider is the cheaper path than re-declaring every object in Python.

FAQ

Why does the deploy succeed but the service return no response?

Almost always a label or port mismatch. Check kubectl get endpoints — an empty subset list means the service matched no pods. If endpoints are populated, the next suspect is targetPort pointing at a port the container does not listen on.

Should the image tag be a digest?

For production, yes. A tag can be repointed at a different image, so two deploys of the same tag are not guaranteed to be the same software. A digest is immutable and makes the Pulumi diff reflect an actual code change.

Do I need an ingress?

Only for traffic from outside the target cluster. ClusterIP is sufficient for service-to-service calls and is the safer default, since it exposes nothing beyond in-cluster DNS.

How do replicas interact with the readiness wait?

Pulumi waits for the deployment's available replica count to reach the desired count. A rollout where one replica never becomes ready will time out the apply, which is the intended behaviour.

Why did Pulumi name my Deployment api-7f3a1b2c?

Auto-naming is the default: Pulumi appends a random suffix so it can create a replacement object before deleting the original. Set metadata.name explicitly only where the name is a contract, such as a Service that other workloads resolve by DNS.

How do I roll back a bad deploy?

Revert the image in your program and run pulumi up — that is the only path that keeps state and reality in agreement. A kubectl rollout undo fixes the workload but leaves the Pulumi checkpoint describing the version you no longer run, and the next apply will quietly restore the broken image.