Bringing Them Together in Docker Containers
PHP projects need Composer for backend dependencies and Node for asset compilation. Forcing both into a single container is the wrong move, and running both on the host is impractical. Multi-stage builds and cleanly separated build containers solve this elegantly.
Table of Contents
- 1. The Core Problem: Node and PHP in One Project
- 2. Clear Role Separation: Build Containers vs. Runtime Containers
- 3. Running Composer in Its Own Container
- 4. Compiling Node Assets in a Separate Build Stage
- 5. A Multi-Stage Dockerfile for PHP-Node Projects
- 6. Controlling Build Order in Docker Compose
- 7. Layer Caching for Fast Build Times
- 8. Hybrid Builds in CI/CD Pipelines
- 9. Build Strategies Compared
- 10. Summary
- 11. FAQ
1. The Core Problem: Node and PHP in One Project
Modern PHP projects such as Magento with Hyva, Laravel with Vite, or Symfony with Webpack Encore are hybrid projects: they need PHP and Composer for server-side logic, plus Node and Composer in Docker containers for the frontend asset pipeline. The obvious solution, packing all tools into a single Docker image, results in huge images, long build times, and difficulty updating PHP and Node versions independently of one another.
The other obvious solution, PHP in a container and Node on the host, brings the opposite problem: developers need the right Node version on their local machine, build outputs can vary slightly depending on the host's Node version, and CI servers need both Node and Docker installed. The right approach for Node and Composer in Docker projects is a clean separation of roles: Node in a dedicated build container, Composer likewise in its own container, PHP-FPM as the runtime container, all three coordinated through Docker Compose and multi-stage Dockerfiles.
2. Clear Role Separation: Build Containers vs. Runtime Containers
The most important conceptual split in Node and Composer in Docker projects is the one between build containers and runtime containers. Build containers run steps that produce artifacts: the Composer installation produces the vendor folder, and the Node build produces compiled CSS and JS files. Runtime containers execute those artifacts but do not need the build tools themselves. A PHP-FPM container needs neither Composer nor Node, only the already installed PHP packages and the finished assets.
This separation of roles has a direct impact on image size and security in Node and Composer in Docker setups. A runtime image without a Composer binary and without a Node installation has a smaller attack surface. If a security vulnerability is discovered in Node, it only affects the build container, not the running application. And the runtime container stays lean: no npm dependencies, no Node interpreter, no compiler tools, just PHP and the finished code.
# Multi-stage Dockerfile for PHP + Node + Composer project
# Stage 1: Composer install, PHP with Composer only
FROM composer:2.8 AS composer-install
WORKDIR /app
# Copy dependency manifest first to maximize cache reuse
COPY src/composer.json src/composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-plugins \
--prefer-dist \
--optimize-autoloader \
--no-interaction
# Stage 2: Node build, compile frontend assets
FROM node:20-alpine AS node-build
WORKDIR /app
# Copy package manifest first for better caching
COPY src/app/design/frontend/Mironsoft/default/web/tailwind/package.json \
src/app/design/frontend/Mironsoft/default/web/tailwind/package-lock.json \
./web/tailwind/
RUN cd web/tailwind && npm ci --prefer-offline
# Copy templates needed for Tailwind purge/scan
COPY src/app/design/frontend/Mironsoft/default/ ./frontend/
RUN cd web/tailwind && npm run build
# Stage 3: Runtime, PHP-FPM without build tools
FROM php:8.4-fpm-alpine AS runtime
WORKDIR /var/www/html
# Copy only the artifacts, no Composer, no Node
COPY --from=composer-install /app/vendor ./vendor
COPY --from=node-build /app/frontend/web/css ./web/css
COPY src/ .
3. Running Composer in Its Own Container
In local development with Docker Compose, Composer ideally runs as a short-lived service that executes once and then exits. The Mark Shust setup ships the wrapper script bin/composer, which spins up a temporary container from the current Composer image, mounts the project directory, and runs the command. The result, the vendor folder, stays on the host and gets mounted as a volume into the PHP-FPM container.
For Node and Composer in Docker projects, the Composer version used in the container is critical for reproducible builds. Instead of composer:latest, always use a pinned version such as composer:2.8. That prevents a Composer update from silently slipping into the build process without an explicit decision. In CI pipelines, the Composer container runs as its own job step, and the resulting vendor folder is passed as an artifact to the next step.
4. Compiling Node Assets in a Separate Build Stage
The Node build container in Node and Composer in Docker projects has a clearly defined job: it takes template files and configuration, runs the asset build (compiling Tailwind CSS, bundling JavaScript), and produces the finished files that the runtime container ships. Everything else, the node_modules folder, the build tools, the Tailwind configuration, stays in the build stage and never ends up in the final image.
For Hyva projects using Tailwind CSS v4, the build step is critical: Tailwind scans every template file to remove unused utility classes. The Node container therefore needs access to all phtml and HTML files in the theme directory. In a Node and Composer in Docker multi-stage build, these template files are copied into the build stage, Tailwind scans them, and the generated CSS is copied into the runtime stage, without the templates themselves ending up in the CSS build artifact.
5. A Multi-Stage Dockerfile for PHP-Node Projects
The multi-stage Dockerfile is the central tool for unifying Node and Composer in Docker within a single build process. Each stage has its own base image and its own context, but artifacts can be transferred from one stage to the next using COPY --from=stage-name. The end result is a lean runtime image that contains only the outputs of the build stages, not the build tools themselves.
For more complex Node and Composer in Docker setups, it is worth defining a shared base stage that contains system dependencies such as SSL certificates, PHP extensions, and basic tools. Both the Composer stage and the Node stage build on top of this base stage. That prevents the same system packages from being installed multiple times. BuildKit, which has been the default since Docker 23, automatically parallelizes independent stages, so the Composer install and the Node build run simultaneously whenever they do not depend on each other.
# Development Compose override: run Node and Composer as on-demand services
services:
# On-demand Composer service, run with: docker compose run --rm composer install
composer:
image: composer:2.8
volumes:
- ./src:/app
- composer-cache:/tmp/composer-cache
working_dir: /app
environment:
COMPOSER_CACHE_DIR: /tmp/composer-cache
profiles:
- tools # Only starts when explicitly requested
# On-demand Node service, run with: docker compose run --rm node npm run build
node:
image: node:20-alpine
volumes:
- ./src/app/design/frontend/Mironsoft/default:/app
- node-modules:/app/web/tailwind/node_modules
working_dir: /app/web/tailwind
command: npm run build
profiles:
- tools
# Dev watcher variant, run with: docker compose run --rm node-watch
node-watch:
image: node:20-alpine
volumes:
- ./src/app/design/frontend/Mironsoft/default:/app
- node-modules:/app/web/tailwind/node_modules
working_dir: /app/web/tailwind
command: npm run watch
profiles:
- tools
volumes:
composer-cache:
node-modules: # Named volume: npm install once, reuse across runs
6. Controlling Build Order in Docker Compose
Docker Compose offers depends_on as a way to define service startup order. For Node and Composer in Docker build workflows, though, that is not enough, because depends_on only waits until a container has started, not until a build process has finished. The correct solution comes down to two approaches: either build services get tagged with profiles and are run manually in the correct order, or the build runs entirely inside the multi-stage Dockerfile, which Docker processes internally in the right order.
For local development with Node and Composer in Docker, a wrapper script that runs the build steps in the correct order is recommended: Composer install, then Node build, then static content deploy. This script also serves as documentation of the build dependencies. In CI/CD pipelines, the same order is defined explicitly as job steps, with each step passing its success on as an artifact to the next.
7. Layer Caching for Fast Build Times
The single biggest performance lever in Node and Composer in Docker builds is layer caching. Docker caches every build step as a layer and only re-executes it when the inputs to that step have changed. The key consequence for package installs: composer.json and composer.lock must be copied before the rest of the source code. If only a PHP file changed, the Composer install step stays cached and gets skipped, even if the build starts in a brand new container.
The same strategy applies to the Node build: copy package.json and package-lock.json first, run npm ci, then copy the template files and start the build. For Node and Composer in Docker projects running in CI with BuildKit, layer caches can be exported and imported between builds using --cache-from and --cache-to. In GitHub Actions, the BuildKit cache is typically stored in the registry, so CI builds also benefit from cached layers.
8. Hybrid Builds in CI/CD Pipelines
In CI/CD pipelines, Node and Composer in Docker projects have two fundamental options: either everything runs inside the Docker build (a multi-stage Dockerfile where docker build produces the finished image), or CI-native tools run Composer and Node and package the results into the Docker image. The first option is more reproducible, since everything happens inside the container, identically on CI and locally. The second option is often faster, because CI providers offer native caching for vendor and node_modules.
For Magento projects with Hyva, both approaches are typically combined: Composer runs natively in CI with a cached vendor folder, the Node build also runs natively with a cached node_modules folder, and the Docker image is then built from the prepared source code without having to run Composer or npm again. In this case, the Dockerfile only defines the runtime container. Explicitly separating Node and Composer in Docker build steps makes them independently parallelizable.
9. Build Strategies Compared
There are several approaches to organizing Node and Composer in Docker builds. Each has strengths and weaknesses depending on project size and team setup.
| Strategy | Reproducibility | Build Speed | Recommendation |
|---|---|---|---|
| Everything in one image | High | Slow, large image | Avoid |
| Multi-stage Dockerfile | Maximum | Medium with caching | Recommended for CI/prod |
| Separate build containers in Compose | High | Fast (volumes) | Recommended for local development |
| CI-native tools + Docker for runtime | Medium | Very fast (CI cache) | Good for large teams with CI |
| Host tools (no containers) | Low | Fast | Avoid, not reproducible |
For Magento projects with Hyva and the Mark Shust setup, the recommended combination is: a multi-stage Dockerfile for the production build and separate build containers in Compose for local development. That gives maximum reproducibility in the deploy process and maximum development speed locally, because the named volumes for vendor and node_modules do not get rebuilt on every code change.
Mironsoft
PHP-Node Docker architecture, CI/CD pipelines, and hybrid build setups
Want Node, PHP, and Composer cleanly containerized?
We design the build architecture for your PHP-Node project: multi-stage Dockerfiles, a layer caching strategy, and CI pipelines that are fast and reproducible.
Dockerfile design
Multi-stage Dockerfiles with optimized layer caching for PHP-Node projects
CI pipeline
GitHub Actions or GitLab CI with BuildKit caching for fast builds
Dev environment
Local Compose setups with separate build containers and named volumes
10. Summary
Cleanly separating Node and Composer in Docker projects starts with a single question: what is a build artifact, and what is a runtime component? Composer packages and compiled CSS/JS files are artifacts, they get produced and then copied into the runtime container. Build tools, node_modules, and the Composer binary are not runtime components and do not belong in the final image. Multi-stage Dockerfiles implement this separation at the Dockerfile level, and separate Compose services implement it at the development environment level.
Layer caching is the single biggest performance lever: copy package manifests before the source code so installs stay cached. Pinned versions for composer:2.8 and node:20 secure reproducibility. Named volumes for vendor and node_modules in local development avoid unnecessary reinstalls. The result is a Node and Composer in Docker setup that is fast and reproducible, identical locally and in CI.
Node, PHP, and Composer in Docker: The Essentials at a Glance
Role separation
Build containers produce artifacts. Runtime containers execute them. PHP-FPM needs neither Composer nor Node, just the finished outputs.
Multi-stage build
COPY --from=stage transfers artifacts between stages. BuildKit automatically parallelizes independent stages for shorter build times.
Layer caching
Always copy composer.json and package.json before the source code. Packages only get reinstalled when the lock file changes.
Local development
Separate Compose services with profiles: tools for on-demand builds. Named volumes for vendor and node_modules avoid reinstalls.