Skip to main content

Step 3 — The .deploy folder

CI builds your image from one fixed path: .deploy/docker/build/app.Dockerfile. This page tells you what goes in that folder for each chart, which project to copy it from, and how to test it locally before CI ever runs.

You do not have to understand Dockerfiles to finish this step. Copy the reference project's folder, let the prompt generator's project prompt adapt it, then run the local test.

Layout

.deploy/docker/build/app.Dockerfile ← always, this exact path
.deploy/docker/build/.dockerignore ← always
.deploy/docker/build/Caddyfile ← php-generic + symfony
.deploy/docker/build/php-override.ini ← php-generic
.deploy/docker/runtime/php.ini ← symfony only
.deploy/docker/runtime/opcache.ini ← symfony only
.container-init.sh ← repo root, optional (migrations)

One image per project. Cron jobs and queue workers run the same image with a different command (step 5), so never add a second Dockerfile.

Rules every Dockerfile follows

RuleWhy
First line # syntax=docker/dockerfile:1.7Enables cache mounts used below.
ARG NODE_VERSION, ARG PNPM_VERSION, ARG PHP_VERSION and use them in FROMCI passes these (24, 11, 8.5). Never hardcode a version.
The OCI_* ARG/LABEL block in the final stageCI passes build metadata; keep it verbatim.
EXPOSE 3000 (sveltekit, node-generic) or EXPOSE 80 (php-generic, symfony)Must match the chart's container port.
ENV NODE_ENV=production / APP_ENV=prodProduction build.
No COPY .env, no secrets as build argsConfig comes from Infisical at runtime.
Lockfile-driven installs (pnpm install --frozen-lockfile, composer install from composer.lock)Reproducible builds.
CMD [...] in exec form, no ENTRYPOINTThe chart overrides command: for cron/workers.
Node images: COPY .container-init.sh into the final stage if you have oneThe init container runs it from the image.

Copy from

Chart / stackCopy .deploy/ fromAlso copy
sveltekitziggo-espn-quiz-fe
node-generic, NestJS + Prismann-backstory.container-init.sh
node-generic, Node without a build stephardloopsupporter-api
php-generic, Craft CMSonyourmarks-websitepublic/ready.php, .container-init.sh
php-generic, api-simpleziggo-espn-quiz-be (no frontend build)
symfonywedstrijd-van-je-leven

Clone the reference next to your project if you don't have it:

git clone git@gitlab.com:onyourmarks/k8s/<reference>.git ../<reference>
cp -R ../<reference>/.deploy .deploy

sveltekit and node-generic

The SvelteKit Dockerfile in full (this is ziggo-espn-quiz-fe; the NestJS one differs only in the lines marked below):

# syntax=docker/dockerfile:1.7

ARG NODE_VERSION
FROM node:${NODE_VERSION}-alpine AS base
WORKDIR /app
ENV NODE_ENV=production

ARG PNPM_VERSION
RUN corepack enable && corepack prepare pnpm@latest-${PNPM_VERSION} --activate

FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./

# Keep pnpm store cached across builds
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile

FROM base AS build
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
RUN pnpm run build

FROM node:${NODE_VERSION}-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# OCI labels
ARG OCI_CREATED
ARG OCI_TITLE
ARG OCI_DESCRIPTION
ARG OCI_SOURCE
ARG OCI_REVISION
ARG OCI_VERSION
ARG OCI_URL
ARG OCI_VENDOR
ARG OCI_LICENSES

LABEL org.opencontainers.image.created=$OCI_CREATED \
org.opencontainers.image.title=$OCI_TITLE \
org.opencontainers.image.description=$OCI_DESCRIPTION \
org.opencontainers.image.source=$OCI_SOURCE \
org.opencontainers.image.revision=$OCI_REVISION \
org.opencontainers.image.version=$OCI_VERSION \
org.opencontainers.image.url=$OCI_URL \
org.opencontainers.image.vendor=$OCI_VENDOR

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/build /app/build

EXPOSE 3000
CMD ["node", "build"]

What to adapt:

SituationChange
No pnpm-workspace.yaml in the repoRemove it from both COPY lines (the build fails on a missing file).
NestJSFinal stage copies dist/ instead of build/, plus .container-init.sh, prisma/, prisma.config.ts and any runtime dirs (public/, views/). CMD ["node", "dist/src/main.js"] or dist/main.js, whichever pnpm build produces. Run pnpm exec prisma generate in the deps stage after install.
Memory-hungry SSRCMD ["node", "--max-old-space-size=384", "build"] (see oym-paddock-fe) and raise resources in step 5.
pnpm complains "verify-deps"Add ENV CI=true in base and pnpm install --frozen-lockfile --offline && pnpm run build in the build stage (see runningcoach-dashboard).

.deploy/docker/build/.dockerignore (same for every Node project):

.git
.gitignore
.gitlab
.gitlab-ci.yml
node_modules
.pnpm-store
*.log
.svelte-kit
build
dist
coverage
playwright-report
test-results
.env
.env.*
*.local
.vscode
.idea
.DS_Store
*.md
docker-compose.yml
.ddev
.deploy

php-generic (Craft CMS, api-simple)

The Dockerfile has four stages on top of dunglas/frankenphp:php${PHP_VERSION}:

  1. base: apt packages + PHP extensions (intl pdo pdo_mysql mysqli zip gd bcmath + redis, imagick via pecl), production php.ini, your php-override.ini, composer binary.
  2. deps: composer install --no-dev --prefer-dist --no-scripts --no-autoloader from composer.lock.
  3. build: installs Node + pnpm, pnpm install --frozen-lockfile, pnpm build, then composer dump-autoload --optimize --classmap-authoritative. Skip the Node part if there is no frontend build (ziggo-espn-quiz-be).
  4. runner: COPY --from=build /app /app, the Caddyfile, EXPOSE 80, CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"].

Copy it from the reference project and only touch: the extension list (drop imagick/bcmath if unused), the Node stage (keep or drop), and writable directories (RUN mkdir -p cache && chown -R www-data:www-data cache).

Caddyfile, minimal (api-simple):

:80

root * /app/public
encode gzip

php_server {
try_files {path} {path}/index.php =404
}

file_server

Caddyfile, Craft CMS (probes + Craft rewrite; from nn-supportervanallehardlopers, without the project-specific redirects):

:80 {
root * /app/public
encode gzip zstd

handle /health {
respond "OK" 200
}

handle /ready {
rewrite * /ready.php
php_server
}

handle {
@craft {
not file
not path /favicon.ico
}
rewrite @craft /index.php?p={path}&{query}

php_server
file_server
}
}

php-override.ini, minimum:

variables_order = "EGPCS"
expose_php = Off
display_errors = Off
display_startup_errors = Off

Add memory_limit, upload_max_filesize, post_max_size as the project needs (onyourmarks-website uses 512M / 70M / 70M).

Craft projects also need .container-init.sh (runs on every pod start, before traffic):

#!/usr/bin/env sh
set -e
composer run-script post-autoload-dump
php ./craft up
php ./craft cacheable/flush/config

and keep public/cache on a shared volume (step 5), because the build wrote nothing there and pods must agree.

symfony

Same four stages as php-generic (copy from wedstrijd-van-je-leven). Differences: symfony.lock is copied with the composer files; no php-override.ini but two runtime files copied in the last stage:

COPY .deploy/docker/runtime/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY .deploy/docker/runtime/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini
RUN mkdir -p var/cache var/log /tmp/opcache && chown -R www-data:www-data var /tmp/opcache

opcache.ini enables preloading (opcache.preload = /app/config/preload.php), so config/preload.php must exist. Remember from step 1: the symfony chart always probes, so add the /health and /ready handlers to the Caddyfile.

Test the image locally

Do this before pushing. It catches 90% of CI failures in a minute.

# build exactly like CI does
docker build \
--file .deploy/docker/build/app.Dockerfile \
--build-arg NODE_VERSION=24 --build-arg PNPM_VERSION=11 --build-arg PHP_VERSION=8.5 \
--tag <slug>:local .

# run it with your local env (Node: port 3000, PHP: port 80)
docker run --rm -p 3000:3000 --env-file .env <slug>:local
# PHP: docker run --rm -p 8080:80 --env-file .env <slug>:local

# in another terminal
curl -i localhost:3000/health
curl -i localhost:3000/ready

The DB in .env points at ddev's db host, which the container can't reach, so /ready may return 503 locally. /health must be 200 and the app must start without crashing.

Let Claude Code do it

Phase 3 of the project prompt from the prompt generator copies the reference .deploy folder, adapts it and runs the local test.

Done when

  • .deploy/docker/build/app.Dockerfile exists at exactly that path, plus .dockerignore (and Caddyfile/ini for PHP)
  • docker build succeeds with the three --build-args
  • The container starts and GET /health returns 200 on the chart's port
  • No .env, no secrets, no hardcoded versions in the Dockerfile