A good node js dockerfile and a good PHP dockerfile solve the same underlying problem — package an application and its runtime into a small, reproducible image — but the two ecosystems have different traps. Node's trap is node_modules bloat and dev dependencies leaking into production; PHP's trap is the missing web server, since PHP itself doesn't ship one the way Node does.
How do you write a solid node js dockerfile?
The pattern that holds up well across most Node applications is a multi-stage build: one stage to install dependencies and build, a second, minimal stage to run.
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
A few details matter more than they look:
npm ci, notnpm install.npm ciinstalls exactly what's in the lockfile and fails if it's out of sync, which is what you want in a reproducible build.- Copy
package.jsonand the lockfile before the rest of the source. This lets Docker cache the dependency-install layer separately from your application code, so a source change doesn't force a full reinstall. --omit=devin the final stage. Dev dependencies (test runners, linters, bundlers) have no business in a production image — they add size and, more importantly, attack surface.- Run as a non-root user. The official Node images already include a
nodeuser; use it instead of running as root by default. - Alpine base images are smaller but not always faster to build, since some native npm packages need extra build tooling on Alpine's musl libc. If you hit obscure native-module build failures,
node:20-slim(Debian-based) is a reasonable fallback.
How do you dockerize a PHP application?
PHP's core wrinkle is that php alone doesn't serve HTTP requests — you need PHP-FPM behind a web server (commonly Nginx) or a self-contained image like php:8.3-apache that bundles Apache with mod_php.
A common, simple approach for a Laravel-style app using PHP-FPM plus Nginx as two containers:
FROM php:8.3-fpm-alpine
WORKDIR /var/www/html
RUN apk add --no-cache libzip-dev icu-dev \
&& docker-php-ext-install pdo_mysql zip intl opcache
COPY composer.json composer.lock ./
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php composer-setup.php --install-dir=/usr/local/bin --filename=composer
RUN composer install --no-dev --optimize-autoloader --no-scripts
COPY . .
RUN chown -R www-data:www-data /var/www/html
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
The equivalent guidance to Node's npm ci here is composer install --no-dev — keep development-only packages (test frameworks, debug bars) out of the production image, and pin exact versions via composer.lock rather than letting a build resolve fresh versions.
For a simpler single-container setup, php:8.3-apache with mod_php skips the separate Nginx container entirely, at the cost of some flexibility in how requests are proxied and cached.
What security details get missed most often?
A handful of mistakes show up in both ecosystems repeatedly:
- Copying
.envfiles or secrets into the image. Use a.dockerignorethat excludes.env,.git, and any local config, and inject secrets at runtime through environment variables or a secrets manager instead of baking them in. - Running as root. Both Dockerfiles above explicitly switch to a non-root user; this is easy to skip and easy to miss in review.
- Stale base images.
node:20-alpineandphp:8.3-fpm-alpineboth receive security patches upstream — pinning a tag and never rebuilding means you silently accumulate known CVEs in the base OS packages, independent of anything in your own code. - Unscanned dependencies baked into the image. A
npm ciorcomposer installrun inside a Dockerfile pulls in whatever the lockfile specifies, vulnerabilities included, unless something checks it. This is exactly the gap SCA scanning closes — checking the dependency tree that ends up in the image against known CVE databases, ideally as part of CI before the image ever gets pushed.
FAQ
Should I use Alpine or Debian-based images for Node or PHP?
Alpine produces smaller images and a smaller attack surface, but native dependencies occasionally fail to build against musl libc. If you hit that, -slim (Debian-based) variants are a safe fallback.
How do I keep secrets out of a Docker image?
Never COPY a .env or credentials file into the image. Inject secrets at container runtime via environment variables, Docker secrets, or your orchestrator's secrets manager, and add sensitive files to .dockerignore so they can't be copied by accident.
Do I need a separate Nginx container for PHP-FPM?
For production traffic, yes — PHP-FPM only speaks FastCGI, so you need Nginx (or another web server) in front of it. Single-container php:*-apache images are simpler for small projects but less flexible for scaling and caching.
How often should I rebuild a container image that hasn't changed code?
Regularly — base image OS packages get security patches independent of your application code. A scheduled rebuild (weekly is common) picks up those patches even when your Dockerfile and source haven't changed.