Bind Mounts in Dev Will Lie to Your Face
The convenient dev setup that makes everything work locally is the same one that hides broken deploys until Friday afternoon.
Bind mounts in a Docker Compose dev setup are one of those things that feel like pure upside until they aren't. You edit a file, the change is live instantly, no rebuild needed. It's fast, it's convenient, and it's been quietly lying to you about whether your image actually works.
I've been burned by this more than once. The most recent time cost a client a Friday afternoon rollout window. I'm not making that mistake again, and I want to walk through exactly why it happens and what I do now.
What Bind Mounts Are Actually Doing
When you drop this into a docker-compose.yml:
services:
app:
build: .
volumes:
- .:/var/www/html
...you're telling Docker to shadow whatever's inside the container at /var/www/html with your local filesystem. The COPY instructions in your Dockerfile don't matter at runtime. The image could be completely broken — missing files, wrong permissions, a composer install that never ran — and you'd never know, because your local directory is covering it up.
The image becomes a lie. It exists, it has a tag, CI maybe even built it successfully, but it doesn't represent a working, self-contained artifact. It's just a runtime scaffold that depends on your laptop.
The Drift That Actually Kills You
Here's the specific failure mode I've hit, in rough order of how often they bite:
1. vendor/ not in the image. You run composer install locally, it works great, bind mount serves up your vendor/ directory. Your Dockerfile has RUN composer install in it, but somewhere along the way someone added vendor/ to the bind mount path or the .dockerignore is wrong. You push the image. Production container starts, /var/www/html/vendor is empty. Fatal error on the first request.
2. Generated files missing. Laravel's bootstrap/cache/ files, compiled assets from npm run build, anything that's generated as a step. Locally they exist because you ran those commands on your machine. Inside the image they don't exist because the Dockerfile step that should generate them is broken or was never written.
3. File permissions set wrong. You're running as your local UID (say, 1000) with the bind mount. The container user is www-data. Everything works locally because the bind mount inherits your UID's permissions. In production, with COPY, the files land with whatever ownership the Dockerfile sets — and if you didn't think about that, storage directories fail silently or loudly.
4. .env assumptions. Locally your .env is bind-mounted in. The image has no .env. That's fine if your production setup injects env vars correctly — but I've seen setups where a config file that should be baked into the image was being served from the local mount and nobody noticed for months.
None of these show up until you actually run the image without a bind mount. Which, if your workflow is always docker compose up, you may never do locally.
A Minimal Larvel Dockerfile That Actually Tests Itself
Here's roughly what I use for Laravel projects now. The goal is an image that's genuinely self-contained:
FROM php:8.3-fpm-alpine
RUN apk add --no-cache \
git \
unzip \
libpng-dev \
libzip-dev \
&& docker-php-ext-install pdo_mysql zip gd opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copy composer files first for layer caching
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Now copy the rest of the application
COPY . .
# Run the full autoloader and post-install scripts now that all files exist
RUN composer dump-autoload --optimize && \
composer run-script post-autoload-dump
# Build frontend assets
COPY --from=node:20-alpine /usr/local/bin/node /usr/local/bin/node
COPY --from=node:20-alpine /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node:20-alpine /usr/local/bin/npm /usr/local/bin/npm
RUN npm ci && npm run build && rm -rf node_modules
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]
This image, when built, contains everything. No external dependencies at runtime. I can docker run it anywhere and it works.
Now in docker-compose.yml for development:
services:
app:
build:
context: .
target: app
volumes:
- .:/var/www/html # convenience for hot editing
- /var/www/html/vendor # but keep vendor from the image
- /var/www/html/node_modules
environment:
APP_ENV: local
depends_on:
- mysql
- redis
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: app
MYSQL_ROOT_PASSWORD: secret
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:
The key lines are the anonymous volume mounts for vendor/ and node_modules. Those tell Docker: yes, bind mount the whole project directory, but for these specific subdirectories, use what's in the image, not what's on my host. Your local edits to PHP files are live. The dependencies are what the image built.
This is the pattern that finally stopped the Friday deploys-that-shouldn't-fail.
The Production Smoke Test I Run Before Every Deploy
I added a simple step to my deployment process: run the image locally with no bind mounts before pushing to the registry.
#!/bin/bash
# smoke-test.sh
set -e
IMAGE="myapp:$(git rev-parse --short HEAD)"
echo "Building image..."
docker build -t "$IMAGE" .
echo "Running smoke test (no bind mounts)..."
docker run --rm \
-e APP_ENV=production \
-e APP_KEY=base64:$(openssl rand -base64 32) \
-e DB_CONNECTION=sqlite \
-e DB_DATABASE=/tmp/test.sqlite \
"$IMAGE" \
php artisan --version
echo "Smoke test passed."
It's not a full integration test. It just confirms the image boots and Artisan can initialize without blowing up. That alone catches the missing-vendor and missing-generated-file failures. Takes about five seconds. I've made it a pre-push git hook on any project where a broken deploy would cost real money.
When Bind Mounts Are Fine and When They Aren't
I'm not saying never use bind mounts. For local development they're genuinely useful — you want your code changes to be live without a full rebuild. The problem is using them as a crutch that lets you avoid validating your image.
Bind mounts are fine when:
- You're doing active development and you also periodically rebuild and run without mounts to verify
- The only things mounted are application source files, not generated artifacts
- You have a CI pipeline that builds and tests the image in a clean environment
Bind mounts are a problem when:
vendor/,node_modules/, or any generated directory is included in the mount- Nobody on the team ever runs the image without the mount
- Your CI pipeline skips a real image build and just runs
composer installbare - You've never tested what happens when the image runs in isolation
For anything going to a client environment — staging, production, doesn't matter — I want to be confident the image I'm shipping is the thing that was tested. Bind mounts break that confidence unless you're disciplined about it. Most teams aren't, because the feedback loop only hurts you during deploys, not during development.
The Deeper Issue
The real problem is that Docker Compose for development and Docker for production are doing different jobs, and it's easy to conflate them. Compose is an orchestration convenience tool. The image is the deployable artifact. If your dev workflow never exercises the artifact in isolation, you're testing something different from what you're deploying.
I've seen this pattern cause grief at a biotech client I worked with — they had a perfectly functional local environment that their small dev team ran for six months, and the first time they tried to containerize their staging deploy they discovered half their bootstrap process was happening on the host, not in the image. Took two days to untangle.
Two days they wouldn't have lost if someone had run docker run --rm instead of always docker compose up.
Closing
Bind mounts are a dev convenience, not a correctness guarantee. Build images that work in isolation, use anonymous volumes to protect generated artifacts from your bind mount, and run a smoke test before you push. It takes maybe an hour to set up right once, and it'll save you at least one bad Friday.
Need help shipping something like this? Get in touch.