Getting Started with Tilt

John Harris

When developing applications to be deployed on Kubernetes, additional steps are needed during the development workflow. Source code must be packaged into a container and deployed to a respective test environment. A common workflow is outlined below.

  1. Save changes in code editor

  2. Build and tag Docker image

    docker build -t <repository_name>/<image_name>:<tag> .
  3. Push Docker image to registry

    docker push <repository_name>/<image_name>:<tag>
  4. Re-deploy Kubernetes manifests

    kubectl delete -f deployment.yaml
    kubectl apply -f deployment.yaml
  5. Run commands to observe changes in test environment

    kubectl describe pod <pod_name>
    kubectl logs <pod_name>
    kubectl port-forward deployment/<deployment_name> 8080:8080
    curl localhost:8080

This workflow represents the “inner-loop” of development in a cloud-native application. More specifically, these are actions performed many times by a developer in between committing, merging, and testing code in a CI/CD pipeline. The goal of Tilt is to automate the inner-loop and make the development and debugging of cloud-native applications seamless. With Tilt configured, the developer’s inner-loop is simplified to:

  1. Save changes in code editor
  2. View logs and errors of deployed code in UI

This guide walks you through getting started with Tilt in your development environment.


Tilt is the only binary required on your local machine for this guide. You will also need access to a running Kubernetes cluster. If you would like to run Kubernetes locally, VMware recommends using Kind and Docker.

Getting Started

Below is an example directory structure for a “Hello World” application written in Go. If you wish to follow along in your own environment, replicate the following directory structure and copy the respective code to each file.

│   main.go
│   Dockerfile
│   deployment.yaml
│   imagePullSecret.yaml

NOTE: The following Go application is modified from the Ardanlabs public repository.

// Source: main.go
package main

import (

func main() {

    // ===================================================================
    // App Starting
    log.Printf("main : Started")
    defer log.Println("main : Completed")
    // ===================================================================
    // Start HTTP server
    api := http.Server{
        Addr:         "localhost:8080",
        Handler:      http.HandlerFunc(ResponseHandler),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 5 * time.Second,
    // Make a channel to listen for errors coming from the listener. 
    // Use a buffered channel so the goroutine can exit if we don't 
    // collect this error.
    serverErrors := make(chan error, 1)
    // Start the service listening for requests.
    go func() {
        log.Printf("main : API listening on %s", api.Addr)
        serverErrors <- api.ListenAndServe()
    // Make a channel to listen for an interrupt or terminate signal 
    // from the OS. Use a buffered channel because the signal package 
    // requires it.
    shutdown := make(chan os.Signal, 1)
    signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
    // ===================================================================
    // Shutdown
    // Blocking main thread and waiting for shutdown.
    select {
    case err := <-serverErrors:
        log.Fatalf("error: listening and serving: %s", err)
    case <-shutdown:
        log.Println("main : Start shutdown")
        // Give outstanding requests a deadline for completion.
        const timeout = 5 * time.Second
        ctx, cancel := context.WithTimeout(context.Background(), timeout)
        defer cancel()
        // Asking listener to shutdown and load shed.
        err := api.Shutdown(ctx)
        if err != nil {
                "main : Graceful shutdown did not complete in %v : %v", 
                timeout, err)
            err = api.Close()
        if err != nil {
                "main : could not stop server gracefully : %v", 

type Response struct {
    Message string `json:"message"`

// ResponseHandler is an HTTP Handler for returning a message.
func ResponseHandler(w http.ResponseWriter, r *http.Request) {

    responseJson := Response{"Hello, World!"}

    data, err := json.Marshal(responseJson)
    if err != nil {
        log.Println("error marshalling result", err)

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    if _, err := w.Write(data); err != nil {
        log.Println("error writing result", err)
# Source: Dockerfile
# ================
# ================
FROM as build
COPY . /app/
RUN CGO_ENABLED=0 go build -o /go/bin/hello-world main.go

# ================
# ================

COPY --from=build --chown=nonroot:nonroot /go/bin/hello-world /app/hello-world

USER nonroot:nonroot
ENTRYPOINT ["/app/hello-world"]
# Source: deployment.yaml
apiVersion: apps/v1
kind: Deployment
  name: hello-world
      app: hello-world
  replicas: 1
        app: hello-world
        # Reference the Secret described in the .yaml found below
        - name: regcred 
      - name: hello-world
        imagePullPolicy: Always
        - name: hello-world
          containerPort: 8080
        resources: {}

During development, you will likely use a personal or shared image repository that Kubernetes pulls images from. The following .yaml object describes an image pull secret, which is a Secret object that Kubernetes relies on to authenticate with a Docker image registry.

# Source: imagePullSecret.yaml
apiVersion: v1
  .dockerconfigjson: <B64_ENCODED_CREDENTIALS>
kind: Secret
  creationTimestamp: null
  name: regcred

This secret object can be automatically created in Kubernetes with the following command:

kubectl create secret docker-registry regcred \
        --docker-server=<your-registry-server> \
        --docker-username=<your-name> \
        --docker-password=<your-pword> \

Basic Tilt Configuration

Configure tilt by adding the following two files:

// tilt_option.json
  "default_registry": ""
# Tiltfile
# Import options from .json file
settings = read_json('tilt_option.json', default={})
# Configure Tilt registry from imported settings

# Specify which Kubernetes object manages the in-development container image
# Specify name of the in-development container image and its build directory
# Specify name of k8s resource for Tilt to be aware of, and which ports should
# be forwarded to local machine
k8s_resource('hello-world', port_forwards=9000)

It is possible to configure your default_registry in your Tiltfile, but VMware recommends keeping configuration in a separate file to simplify on-boarding for developers.

Your directory structure should now look like this:

│   main.go
│   Dockerfile
│   deployment.yaml
│   imagePullSecret.yaml
│   Tiltfile
│   tilt_option.json

Inner-Loop After Configuring Tilt

With tilt configuration in place, run tilt up to start Tilt. Once Tilt begins running, the following will happen.

  1. The specified Docker image will be built and tagged.
  2. The specified Kubernetes .yaml file will be deployed to whichever cluster your $KUBECONFIG specifies.
  3. The specified port-forwards will be set up on your local machine and managed by Tilt.
  4. A text user-interface will take over the terminal in which tilt up was run. This UI will display information regarding the build and deploy processes, logs related to your in-development container image, and relevant errors.
  5. A tab in your web browser will open with the same information described in step 4. Use whichever display you prefer.
  6. Any changes made to files that Tilt is aware of will trigger steps 2-5 to be performed. This is how Tilt keeps your development environment in-sync with changes made in your local development environment.

For advanced usage of Tilt, refer to the documentation.