CaRMen's backend container builds its Docker image from a Dockerfile that runs composer install during the build, so the image ships with a real, populated /app/vendor baked in. The development docker-compose.yml also bind-mounts the actual source tree over the same path, for the obvious reason every dev setup does that: edit a PHP file on the host, see the change immediately in the running container, no rebuild required.
volumes:- ./backend:/app
Those two things don't coexist the way they look like they should. A bind mount doesn't merge with what the image already has at that path — it replaces it, directory tree and all, for as long as the container runs. vendor/ isn't checked into git, so a fresh checkout's ./backend directory doesn't have one. The moment that bind mount takes effect, the image's carefully-installed /app/vendor — composer autoloader, every dependency, all of it — is gone from the running container's point of view, hidden underneath a host directory that never had those files in the first place.
Why this isn't "forgot to run composer install"
The obvious-sounding fix is to run composer install again inside the container after it starts. Through the bind mount, that writes vendor/ into the host's ./backend directory, so it does stick, but now the host copy is in charge of the dependencies and the image's own build-time install is dead weight. The fix that keeps the image's vendor/ in use is a separate, named Docker volume, mounted at the specific subpath that needs to survive:
volumes:- ./backend:/app- backend_vendor:/app/vendor
Docker resolves overlapping mounts by specificity, not by declaration order: a volume mounted at the more specific path, /app/vendor, wins over the bind mount covering the broader /app, for exactly that subpath. The bind mount still shadows everything else under /app the way it's supposed to — live-editable PHP source, no rebuild needed — but /app/vendor itself is carved out and backed by its own named volume instead, declared once at the bottom of the compose file alongside the database's own data volume. Composer only needs to run again when dependencies change, not every time the container restarts, because the volume persists independently of the container's lifecycle.
A second, unrelated symptom on the same shared machine
The same commit that added the vendor volume also remapped two host ports that had nothing to do with the vendor problem: the frontend's dev server moved from host port 3000 to 3001, and Mailpit's web UI moved from 8025 to 8027. Neither Docker Compose service was misconfigured — each one was perfectly correct in isolation. The conflict only existed because this is one development machine running more than one project, and some other service already had first claim on those same ports by the time CaRMen's containers tried to start. FRONTEND_URL had to move in step with the actual port remap, since a stale value pointing at the old port would have quietly broken CORS for anything checking the request's origin, even though nothing about the remap itself was wrong.
Two different kinds of "nothing here is broken"
Both problems share a shape worth naming: a container-local decision — what gets bind-mounted, which host port a service binds to — can be entirely correct on its own terms and still break the moment something outside that one docker-compose.yml file has an opinion too. The vendor directory wasn't a bug in the Dockerfile or in the bind mount; each did exactly what it was asked to do. The ports weren't misconfigured in any way you'd catch by reading the compose file alone. Neither failure mode shows up until the thing runs next to whatever else happens to be running on the same box, which is a reasonable argument for treating "starts cleanly on a shared dev machine, not just in isolation" as part of what a working compose file has to mean.
Both fixes landed in the same commit as a much larger one — the API Platform 4 upgrade that silently emptied every list in the frontend — not because they were related to it, but because that was the evening the container got rebuilt and restarted enough times for both a stale vendor/ and a port collision to surface. Neither would have been caught by reading the Dockerfile or the compose file in isolation; both only became visible once the containers were brought up next to the rest of what runs on this machine. That's the practical argument for not treating "builds" and "runs cleanly alongside everything else already on the box" as the same question, even though it's tempting to assume that whatever docker compose up doesn't immediately complain about must be fine.
Leave a comment
Comments are reviewed before they appear.