Bringing Node, PHP, and Composer Together in Docker Containers
AI generated
Docker · Node · PHP · Composer · Multi-Stage
Node, PHP, and Composer
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.

11 min read Multi-Stage · Composer · Node · Asset Pipeline · CI PHP 8.4 · Node 20 · Composer 2 · Docker Compose

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.

11. FAQ: Node, PHP, and Composer in Docker

1Why not Node and PHP in the same image?
Large image, coupled updates, build tools in the runtime. Separate stages give flexibility and keep images lean.
2What does COPY --from do in multi-stage builds?
Copies files from a previous build stage. That's how vendor and CSS end up in the runtime container without the build tools themselves.
3How to cache Composer packages optimally?
Copy composer.json and composer.lock before the source code. The install layer stays cached when only PHP files change.
4When to use --no-scripts with composer install?
Always when the source code is still incomplete. Post-install scripts often expect a full project structure. Run scripts separately after the full COPY.
5npm run build in Docker without the host?
docker compose run --rm node npm run build. Starts the container, builds, hands control back. Result lands in the mounted volume.
6When to use composer:2.8 as an image?
As a build stage or a short-lived Compose service. Contains only Composer and PHP. For production images, use your own PHP image as the base.
7Stop npm install running on every change?
Named volume for node_modules. Persists between starts. Install only runs on package.json changes or when triggered manually.
8Composer and Node in containers in CI too?
Not necessarily. CI-native actions with their own caching are often faster. Docker image is then built without a fresh install, vendor is copied directly.
9Does BuildKit parallelize Composer and the Node build?
Yes, when stages are independent. composer-install and node-build run in parallel, shorter total build time for multi-stage builds.
10Private Composer repositories in Docker?
BuildKit secrets for auth tokens. Never put credentials in Dockerfile layers, they stay in the layer cache. Secrets are only available during the build.