November 20, 2025 4 min read By Farrizal Alchudry Mutaqien

Running the Production Dockerfile Locally with Tilt

How I use Tilt to run the same container setup locally that will run in production.

Running the Production Dockerfile Locally with Tilt

I do not like maintaining two versions of an app setup.

One path for local development, another path for production, then a third set of notes explaining why they are different. That is where the usual “works on my machine” bug comes from.

Tilt helps by running the real Dockerfile locally, then syncing code changes into the running container.

Why I reach for Tilt

This is the common split:

local:       npm run dev
production: Dockerfile + Kubernetes

That means different Node versions, different system packages, different file paths, and sometimes different permissions.

The app can pass locally and still fail in the image.

Start with the Tiltfile

The first version can stay small:

# Tiltfile
docker_build('my-app', '.', dockerfile='Dockerfile')

k8s_yaml('k8s/deployment.yaml')

k8s_resource('my-app', port_forwards='3000:3000')

Then run:

tilt up

Your app runs locally through the same image path production uses.

Add live updates

Rebuilding the full Docker image on every save is too slow.

The useful part is live_update:

docker_build(
    'my-app',
    '.',
    dockerfile='Dockerfile',
    live_update=[
        sync('./src', '/app/src'),
        run('npm install', trigger=['package.json', 'package-lock.json']),
    ]
)

Now normal source changes sync into the container. Dependency changes still run the install step.

A small full example

# Tiltfile
k8s_yaml('k8s/postgres.yaml')
k8s_yaml('k8s/redis.yaml')

docker_build(
    'my-app',
    '.',
    dockerfile='Dockerfile',
    live_update=[
        sync('./src', '/app/src'),
        run('npm install', trigger=['package.json', 'package-lock.json']),
    ]
)

k8s_yaml('k8s/deployment.yaml')

k8s_resource(
    'my-app',
    port_forwards='3000:3000',
    resource_deps=['postgres', 'redis']
)

The Dockerfile stays the source of truth:

FROM node:20-alpine

RUN apk add --no-cache python3 make g++

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

CMD ["node", "src/index.js"]

Bugs this finds earlier

This is where the setup pays off for me:

  • Missing Alpine packages.
  • File path casing that works on macOS but fails on Linux.
  • Permission problems from USER node.
  • Lockfile problems from npm ci.
  • Environment assumptions hidden in shell profiles.

That is the main reason I use it.

The tradeoff

Tilt is not as instant as a pure framework dev server.

For a small UI-only project, npm run dev is simpler. I reach for Tilt when the app depends on the production image, native packages, Kubernetes resources, or multiple services.

What I run

brew install tilt-dev/tap/tilt
tilt up

After that, every system package goes into the Dockerfile first.

If production needs a package, I add it there. Local development follows the same path.

Back to blog