log in
consulting hosting industries the daily tools about contact

Laravel Vite in Docker: The Manifest Race and Why Build Context Matters

Vite and Docker seem simple until your assets 404 in staging and you're staring at a manifest.json that may or may not exist yet.

Vite's Laravel plugin is genuinely good when it works, and genuinely maddening when it doesn't. The failure mode I've hit most often isn't the code — it's the container build order and what ends up (or doesn't end up) in public/build when PHP tries to read the manifest.

What Problem Vite Actually Solves Here

Before Vite, Laravel shipped with Mix, which was a webpack wrapper. It worked, but cold build times on a project with a few hundred JS modules were painful. Vite does HMR properly, builds are fast, and the fingerprinting story — where app.js becomes app-3f2a91c4.js in production — is handled cleanly through a manifest.json that Laravel's @vite() blade directive reads at render time.

The manifest maps logical entry points to their hashed, cache-busted filenames. Laravel reads that file on every request (with config caching it once) and emits the right <script> and <link> tags. Clean design.

The problem is that in Docker, your PHP container and your Node build step are not the same thing, and it's surprisingly easy to have PHP start up before the manifest exists, or to COPY the manifest in from the wrong place, or to build assets against one APP_URL and serve them from another.

The Manifest Race Condition

Here's the actual failure. In a docker-compose setup where you have a php service and a separate node build step (or a npm run build baked into the PHP image), the timing matters.

If your docker-compose.yml looks like this:

services:
  php:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - .:/var/www/html
    ports:
      - "8000:8000"

  node:
    image: node:20
    working_dir: /app
    volumes:
      - .:/app
    command: npm run build

...and you docker-compose up both at the same time, PHP will be handling requests before node finishes writing public/build/manifest.json. You'll see this:

Vite manifest not found at: /var/www/html/public/build/manifest.json

Or worse — you'll see it intermittently, depending on how fast the Node build runs on that particular machine, and you'll spend an hour convinced it's a caching issue.

The fix isn't complicated, but it requires you to stop treating asset compilation as an afterthought. Either:

  1. Run npm run build inside the PHP Dockerfile before the container starts, so the manifest is baked in.
  2. Use a proper depends_on with a health check that actually verifies the manifest file exists.
  3. Use a multi-stage Dockerfile that builds assets in a Node stage and copies them into the PHP stage.

Option 3 is what I ship for client projects now.

The Multi-Stage Build That Actually Works

# Stage 1: Node — build assets
FROM node:20-alpine AS node-builder

WORKDIR /app

COPY package.json package-lock.json vite.config.js /app/
COPY resources/ /app/resources/

RUN npm ci --prefer-offline
RUN npm run build

# Stage 2: PHP — application image
FROM php:8.3-fpm-alpine AS php-app

WORKDIR /var/www/html

# ... your PHP extension installs, composer, etc.

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock /var/www/html/
RUN composer install --no-dev --optimize-autoloader --no-scripts

COPY . /var/www/html/

# Pull built assets from the Node stage
COPY --from=node-builder /app/public/build /var/www/html/public/build

RUN php artisan config:cache \
 && php artisan route:cache \
 && php artisan view:cache

The key line is COPY --from=node-builder /app/public/build /var/www/html/public/build. The manifest is guaranteed to exist before config:cache runs, before the container starts serving requests, before anything.

No race. No timing dependency. The manifest is a file that was written in a previous stage.

Build Context Gotchas

The build context is what Docker sends to the daemon when it processes your Dockerfile. If your .dockerignore is wrong, you'll either bloat the context (slow builds) or exclude files you need.

I've made both mistakes. The one that bit me hardest: I had node_modules/ in .dockerignore (correct) but I also had resources/ half-ignored because of a leftover rule from an old project. The Node stage would COPY resources/ and get an empty directory. The build would succeed — Vite doesn't error out if there's nothing to bundle if your entry points are missing — and I'd get a manifest with zero entries. PHP would render pages with no scripts or styles attached. Fun to debug at 11pm.

My .dockerignore for a Laravel project these days:

.git
.github
.env
.env.*
!.env.example
node_modules
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
public/build

Notice public/build is ignored in the context — it should only come from the Node build stage, not from your local machine. This prevents the classic situation where you built assets locally with dev settings, docker build picked them up via COPY, and now your production container is serving unminified, sourcemapped, localhost-pointing assets.

Asset Fingerprinting and the APP_URL Problem

Vite's fingerprinting is content-based hashing. The filename changes when the file content changes. That part works great and you don't need to do anything special to enable it — npm run build produces hashed filenames by default.

What catches people is the base URL. In vite.config.js, if you're not explicit:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

This is fine for most cases because Laravel's @vite() directive reads the manifest and generates URLs using your app's asset() helper, which respects APP_URL. But if you have assets that reference other assets — fonts, background images in CSS — Vite needs to know the public base path at build time.

If you're serving from a subdirectory or CDN, set it explicitly:

export default defineConfig({
    base: process.env.ASSET_URL ? `${process.env.ASSET_URL}/build/` : '/build/',
    plugins: [
        laravel({ input: ['resources/css/app.css', 'resources/js/app.js'] }),
    ],
});

And in your Dockerfile build args:

ARG ASSET_URL
ENV ASSET_URL=${ASSET_URL}
RUN npm run build

I had a client with a CloudFront distribution in front of their app. Assets were uploading fine to S3, PHP was generating the right manifest URLs, but the CSS-internal font references were still pointing at /build/fonts/... because Vite baked in the wrong base at compile time. Took me longer than I'd like to admit.

The HMR Story in Docker Dev Environments

For local development, Vite's HMR needs the dev server to be reachable from your browser — not from inside the PHP container. This trips people up because they configure VITE_DEV_SERVER_URL or server.host for container-to-container networking, when what matters is browser-to-container.

In vite.config.js for local Docker dev:

export default defineConfig({
    server: {
        host: '0.0.0.0',
        port: 5173,
        hmr: {
            host: 'localhost',  // what the browser resolves
        },
    },
    plugins: [laravel({ input: [...], refresh: true })],
});

And expose port 5173 from your node service in docker-compose.yml. The PHP app in dev mode reads VITE_DEV_SERVER_URL from .env to know whether to emit the dev server script tag or fall back to the manifest:

VITE_DEV_SERVER_URL=http://localhost:5173

Don't set VITE_DEV_SERVER_URL in production. If it's set, Laravel will try to load from the Vite dev server regardless of whether one is running, and you'll get a 404 on the script tag. I've seen this cause a staging incident because someone copy-pasted the dev .env as a starting point.

When I'd Reach for This Setup

Every Laravel project I containerize gets the multi-stage Dockerfile approach above. It's maybe 20 extra lines and it eliminates an entire class of environment-specific asset bugs.

If you're doing straight server deploys with Forge or Envoyer, you don't have the race condition problem because your deploy script runs npm run build before restarting PHP-FPM. That's fine. This complexity is Docker-specific.

I wouldn't reach for anything more elaborate — no separate asset compilation services, no external manifest stores — for typical Laravel apps. The multi-stage build is the right level of complexity for this problem.

Where I Wouldn't Bother

If you're building a simple app with no custom JS or just Alpine.js sprinkles, you might not need Vite at all. A CDN-linked Alpine and Tailwind via CDN in dev, with the Tailwind CLI as a build step, might be simpler. Vite's value shows up when you have real JS modules, TypeScript, Vue or React components, or CSS that requires a proper PostCSS pipeline.

Also: if your Docker image build time is already pushing 10 minutes and npm ci adds another 3, that's worth measuring. Sometimes splitting assets into a separately-cached image layer — or pre-building assets outside Docker entirely and just COPY-ing the output — is the right call for your CI pipeline.


The manifest race condition is one of those bugs that looks mysterious the first time and obvious in retrospect. Multi-stage builds solve it cleanly. Get your build context right, don't let local assets leak into your production image, and be explicit about base URLs if you're using a CDN. The rest of Vite's Laravel integration is solid — it's just Docker that requires you to be deliberate.

Need help shipping something like this? Get in touch.