By this point we already have everything we need: a local k3d cluster named dev (see the k3d chapter), a Dockerfile for our myapp service (see the containerization chapter), and a set of Kubernetes manifests (see the manifests chapter). But if you try to develop "by hand," the loop is excruciating: you change a line of Python code → docker builddocker push to k3d-registry.localhost:5000kubectl rollout restart → wait for the new Pod to come up → check the logs with kubectl logs. A minute or two for every edit. Ten edits, and half an hour of your life is gone to waiting.

Tilt solves exactly this pain. It's a tool that orchestrates your inner dev loop: it watches your files, rebuilds the image for you, deploys to the cluster for you, and most importantly, it can update code directly inside a running container in seconds. On top of that, it gives you a web dashboard where all your services, their statuses, and their logs live in one place. Let's see how it works.

What a Tiltfile is and what it's made of

Tilt's configuration lives in a file named Tiltfile (no extension) at the root of your project. It's not YAML or JSON — it's a program written in Starlark, a dialect of Python. That means it has functions, loops, lists, conditionals — everything you'd expect in plain Python, just trimmed down to a safe, deterministic subset. For a beginner that's both a plus and a minus: the syntax is familiar, but you'll have to get used to the idea that this is code, not a declarative config.

When you run tilt up, Tilt executes the Tiltfile from top to bottom. Functions like docker_build() and k8s_yaml() don't do anything "right now" — they register configuration. From those registrations, Tilt builds a graph of resources: what needs to be built, what needs to be deployed, and what depends on what. Then Tilt starts watching your files and, when something changes, automatically rebuilds and redeploys whatever was affected. If you change the Tiltfile itself, Tilt re-executes it from scratch — nothing to restart.

The most minimal meaningful Tiltfile is literally two lines: "how to build the image" and "what to deploy." Everything else is fine-tuning.

docker_build + k8s_yaml + k8s_resource — the three pillars

Three functions hold up almost any Tiltfile:

  • docker_build('tag', 'context') how to build the image. The first argument is the image tag (for example, myapp), the second is the path to the build context (usually .).
  • k8s_yaml('file') what to deploy. Registers your Kubernetes manifests.
  • k8s_resource('name', ...) fine-tuning for a resource Tilt has already assembled: port forwarding, renaming, grouping.

The magic is in how Tilt ties these three things together. It scans the YAML you pass in, finds the workloads in it (Deployment, StatefulSet, and so on), and creates a resource for each one. Then it matches the images from docker_build against the images declared in the manifests by matching the tag. When there's a match, Tilt swaps the tag in the manifest for the freshly built image (with a unique ID, so Kubernetes is guaranteed to pick up the new version) and deploys it. Related Services are pulled into the same resource automatically.

Here's a working Tiltfile for myapp:

1# Tiltfile
2
3# How to build the myapp image from the current directory
4docker_build('k3d-registry.localhost:5000/myapp', '.')
5
6# What to deploy — our manifests from the manifests chapter
7k8s_yaml(['k8s/deployment.yaml', 'k8s/service.yaml'])
8
9# Fine-tuning: forward the Pod's container port 8080 to localhost:8080
10k8s_resource('myapp', port_forwards='8080:8080')

The tag k3d-registry.localhost:5000/myapp here has to match the image: in your deployment.yaml — otherwise Tilt won't link the build to the deploy and will simply deploy whatever is in the manifest without rebuilding.

After tilt up, the service will be available at localhost:8080, and the k8s_resource with port_forwards saves you from running kubectl port-forward by hand. Note: 8080:8080 here forwards to the Pod's container port (8080), where uvicorn is listening, not to the Service port (80 from the manifests chapter) — Tilt forwards straight into the Pod, bypassing the Service. An important rule: one docker_build per image you're actively developing. If you have a monorepo of five services but right now you're only touching myapp, build only that one with Tilt and let the rest run from prebuilt images.

Live Update: updating code in a running container in seconds

This is Tilt's killer feature. The usual "rebuild the image → redeploy the Pod" loop takes tens of seconds even for a small service: Docker layers, push to the registry, pull in the cluster, Pod restart. Live Update breaks that loop: instead of rebuilding, Tilt copies the changed files directly into the already-running container and, if needed, runs commands there. This takes seconds.

You configure it through the live_update=[...] parameter inside docker_build(). The steps run in a strictly defined order, and that order must not be broken:

  1. fall_back_on(['path']) — only at the very beginning. If the specified file changes, Live Update is canceled and a full rebuild kicks in. The logic is simple: some changes can't be slipped into a container on the fly.
  2. sync('local', '/in-container') — the foundation of it all. Copies files from a local directory into the container.
  3. run('command', trigger=[...]) — runs a command inside the container. The optional trigger restricts the run to only certain changes.
  4. restart_container() — last. Mainly for Docker Compose; for Kubernetes, use the approach in the hot reload section.

Tilt makes its decision based on a simple tree: fall_back_on triggered → full rebuild; the file matched a sync → fast live update; the file is in the build context but not covered by any sync → full docker build; the file isn't tracked at all → nothing happens.

Two limitations that trip up every newcomer:

  • sync paths must live inside the build context of the same docker_build. Tilt's motto: "If Tilt is watching it, you can sync it." If you sync a path outside the context, the live update silently won't fire.
  • run() cannot come before sync(). First put the files in place, then do something with them.

And remember: the first deploy is always a full one. Live Update needs an already-running container to copy files into. It doesn't speed up a cold start.

A typical example for a Python service is to sync the code and run the heavy pip install only when dependencies change:

1docker_build(
2    'k3d-registry.localhost:5000/myapp',
3    '.',
4    live_update=[
5        # if dependencies changed, it's simpler to rebuild the whole image
6        fall_back_on(['requirements.txt']),
7        # sync code instantly (into /code/app — the WORKDIR from the containerization Dockerfile)
8        sync('./app', '/code/app'),
9        # reinstall dependencies just in case, but only if the file changed
10        run('pip install -r requirements.txt', trigger=['requirements.txt']),
11    ],
12)

Here fall_back_on(['requirements.txt']) and run(..., trigger=['requirements.txt']) partly overlap in intent — in a real project you'd usually pick one approach. fall_back_on is more reliable (a full clean rebuild), run is faster (installing into the live container). For a beginner I'd recommend fall_back_on: less chance of ending up with a "dirty" container state.

Hot reload for your stack (uvicorn --reload and friends)

Live Update delivered the new files into the container — but how do you make the application pick them up? There are two scenarios here.

Scenario A: the framework can hot-reload on its own. Our myapp runs FastAPI via uvicorn, and uvicorn has a --reload flag: an internal watcher tracks .py files and restarts the process when they change. The Flask dev server can do the same. In this case sync is enough — the files land in the container, the watcher inside sees them and reloads the application. Nothing extra to configure.

The launch command inside the container looks like this:

1uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload --reload-dir app

A subtle point: uvicorn --reload restarts the entire process on every change — it starts the Python interpreter from scratch and re-imports all modules. On a large application this is noticeably slower than a targeted file swap. The --reload-dir ./app flag narrows the watcher's scope to just the directory you care about, so it doesn't twitch on every temporary file (the --reload and --reload-dir flags are described in uvicorn's settings). Running with --reload is a development mode; in the chapter on getting closer to prod we'll discuss why you shouldn't enable reload in a production image.

Scenario B: there's no hot-reload. Say you have a Go binary or a Python service started without --reload — then the new files sit in the container but the old process doesn't know about them. You need to restart the process after the live update. For Kubernetes resources, the restart_process extension does this:

1load('ext://restart_process', 'docker_build_with_restart')
2
3docker_build_with_restart(
4    'k3d-registry.localhost:5000/myapp',
5    '.',
6    entrypoint='python -m uvicorn app.main:app --host 0.0.0.0 --port 8080',
7    live_update=[
8        sync('./app', '/code/app'),
9    ],
10)

docker_build_with_restart wraps the regular docker_build and, after each live update, re-runs the command from entrypoint. This is the modern replacement for the deprecated restart_container(), which is no longer recommended for Kubernetes (it remains relevant mainly for Docker Compose).

restart_process has limitations: it doesn't work without a shell in the image, doesn't work if the launch command is set in the Kubernetes manifest (rather than in the image itself), doesn't work with Docker Compose, and doesn't work with some builds using custom_build. For our myapp, scenario A (uvicorn --reload) is simpler and preferable — keep restart_process in mind for a stack without built-in reload.

The Tilt web dashboard: logs, statuses, manual triggers

When you run tilt up, a web UI comes up at localhost:10350 — Tilt's default port. The dashboard is one of the reasons Tilt is especially good for beginners: the entire state of your dev environment is visible on a single screen, without juggling a dozen terminals.

What the dashboard shows:

  • A list of resources, grouped by labels (you can set labels in the Tiltfile). Each resource has two statuses: update (how the build/deploy went) and runtime (what's happening with the Pod right now).
  • The Pod ID with a copy button — handy for quickly firing off kubectl by hand.
  • Endpoints as clickable links — the very port_forwards we set in k8s_resource.
  • Detailed logs with filtering: by source (build or runtime), by level (errors/warnings only), and by keyword or regex. This makes it much easier to work out why a Pod won't start — more on debugging in the chapter on observability.
  • A Trigger Update button — manually triggers a rebuild and redeploy of a specific resource.

Sometimes automatic mode gets in the way — for example, you want to save files as you go but only rebuild when you decide to. Tilt supports a manual update mode. You can switch everything to manual mode at once, or just individual resources:

1# globally — manual mode
2trigger_mode(TRIGGER_MODE_MANUAL)
3
4# but let this specific resource update automatically
5k8s_resource('myapp', trigger_mode=TRIGGER_MODE_AUTO)

In manual mode the UI draws a star next to a resource that has unapplied changes, along with a button to apply them. Handy when there are lots of edits and you don't want to run a deploy on every one.

You can stop everything and remove the resources from the cluster with tilt down — it tears down what Tilt deployed.

How Tilt differs from Skaffold and DevSpace

Tilt isn't the only tool in this niche. It's most often compared with Skaffold and DevSpace. All three do roughly the same thing: build an image, deploy it to the cluster, and sync files for a fast loop. The differences are in the approach.

TiltSkaffoldDevSpace
License / authorApache-2.0, TiltGoogleopen-source
ConfigTiltfile (Starlark)YAMLYAML
Web UIRich, out of the boxNone (CLI)None (CLI)
File sync / liveLive Updatefile synctwo-way sync

The key difference is UI-first vs. CLI-first and Tiltfile vs. YAML.

  • Tilt bets on a visual web dashboard and Live Update. The config is a Starlark program: flexible, but the learning curve is steeper than with familiar YAML. It works well for beginners and for teams with mixed experience — because you can see the entire state with your own eyes.
  • Skaffold is CLI-only, with a YAML config, file sync, profiles for different environments, and a separate debug mode, skaffold debug. A familiar declarative format, but no visual panel.
  • DevSpace is an open-source CLI, also YAML. The devspace dev command gives you two-way sync, reverse port-forward, and dev containers; there are multi-cloud scenarios and an easy install.

A fair caveat: judgments along the lines of "what suits whom best" are subjective and largely drawn from review blogs. If your team lives in YAML and loves the CLI, Skaffold or DevSpace will be a great fit. For the purposes of this article — teaching a beginner to develop comfortably for Kubernetes locally — Tilt wins on the dashboard: less "blind" work in the terminal, more understanding of what's actually happening in the cluster.

In the next chapter we'll connect myapp to its dependency — PostgreSQL — and teach Tilt to bring up the database alongside the service: dependencies: databases, queues, caches.

Sources

Need help setting up Tilt for your team?
Want a fast inner dev loop on Kubernetes with Tilt and Live Update? I can help you set up a Tiltfile and a comfortable local workflow for your team.