Skip to Content
Framework GuidesCustom Dockerfile

Custom Dockerfile

By default, Ghayma auto-generates an optimised Dockerfile.paas for your project based on the framework it detects (Next.js, Node, Static, etc.). The auto-generated path covers most use cases — but when you need full control over the build (custom system packages, a specific base image, non-standard runtimes, headless-browser dependencies), you can provide your own Dockerfile and the platform will use it instead.

This is the same convention as Vercel, Netlify, Render, Heroku, and Fly.io — drop a Dockerfile in your repo and the platform picks it up.

When to use it

  • You need Chrome / Chromium for Puppeteer or Playwright (PDF generation, scraping, screenshots).
  • You need a system package that isn’t in the platform’s standard runtime (ffmpeg, ImageMagick, Postgres clients, etc.).
  • You want to pin a specific base image for reproducibility / compliance reasons.
  • You’re deploying a runtime the platform doesn’t auto-detect (Bun, Deno, Python ASGI, etc.).

If you don’t need any of these things, stick with the auto-generated Dockerfile — it’s tuned for Ghayma’s build cache, image size, and standalone output behaviour, and you’ll get those improvements for free as the platform evolves.

Enabling the feature

The Custom Dockerfile feature is opt-in per project during the alpha phase. The switch is a permission for the whole project — every site of the project may use its own Dockerfile once it is on.

  1. Open your project on the dashboard
  2. Settings → Advanced
  3. Flip the “Use custom Dockerfile” toggle on
  4. Next deploy uses your Dockerfile instead of the auto-generated one

Without the toggle, any Dockerfile in your repo is ignored and the auto-detection path runs as today — for every site, whatever each one is configured with.

Opting one site out

The project switch says custom Dockerfiles are allowed; each site decides whether it uses one. To keep the generated Dockerfile for a single site — an app in a monorepo whose sibling ships a Dockerfile, say — turn on “Always generate the Dockerfile for this site” on that site’s Settings page, or set builder in its config:

.ghayma.json
{ "builder": "auto" }

That site then builds from the generated Dockerfile and ignores any Dockerfile in the upload, including a dockerfile_path. Leave builder out for the default: use your Dockerfile when the project switch is on and the site has one.

The released CLI doesn’t carry builder in a workspace manifest entry yet — use the site’s Settings page for manifest sites until the next CLI release.

How the platform finds your Dockerfile

Two resolution rules, in order:

  1. Explicit path in .ghayma.json — set the dockerfile_path field, relative to the app’s directory (its root_directory, not the workspace root). The file MUST exist at that path or the build will fail with a clear error.

    .ghayma.json
    { "project_id": "...", "name": "my-app", "slug": "my-app", "dockerfile_path": "infra/Dockerfile.production" }

    The path is per site, and a deploy saves it as that site’s default — so later deploys use it even if the key is gone from the config. Change or clear it in the site’s Settings.

  2. Convention — if no dockerfile_path is set, the platform looks for a literal Dockerfile at your project root (or in your root_directory if you have a monorepo configured).

The CLI prints a 🐳 Using your Dockerfile line at deploy time so you can confirm which file the platform picked up. Every build also records its decision in the deploy log:

[build] site=admin builder=auto allow_custom=true dockerfile_path=

How your Dockerfile is built

Your Dockerfile is built by a rootless BuildKit job in the cluster — the same engine behind docker build, so multi-stage builds, heredocs and the rest of the Dockerfile syntax behave the way they do on your machine.

The build context is your whole upload. A site that uploads only its app directory builds with that directory as the context; a site on the "workspace" upload mode uploads the whole workspace and builds root_directory inside it, so COPY can reach a shared package in a sibling directory. dockerfile_path stays relative to the app’s directory either way — see above.

.dockerignore is honoured. A .dockerignore at the root of the context excludes files from the build. Beside a named Dockerfile you can put a <Dockerfile>.dockerignore — Dockerfile.production.dockerignore next to Dockerfile.production — and it applies instead of the root one for that build. The CLI’s own ignore rules run first: anything left out of the upload never reaches the build at all. See Ignoring files from uploads.

Layers are cached between builds. An unchanged stage is reused, so put your dependency install ahead of COPY . . to get the most out of it. Nothing to configure.

Your database variables are build secrets. The build can reach the databases the site is connected to (a Valkey excepted — see Valkey), and their variables — DATABASE_URL and the rest — are offered to it as secrets. A RUN step sees one only when it mounts it:

RUN --mount=type=secret,id=DATABASE_URL,env=DATABASE_URL npx prisma migrate deploy

See Using your database during the build for a full example. Those are the only secrets a build is given — there is no way to add one of your own — so keep every other secret in runtime environment variables.

Multi-platform images are not available: each build produces one image for the cluster’s architecture.

Platform contract

Your Dockerfile must respect these conventions so the resulting container works correctly in the cluster:

FieldConventionRequired?
Listening portRead $PORT (defaults to 3000 if unset). EXPOSE 3000 is informational only — the platform routes based on the env var.Required to receive traffic
HostnameBind to 0.0.0.0, not localhost or 127.0.0.1 — $HOSTNAME is set to 0.0.0.0 in the containerRequired for K8s ingress to reach the pod
Working directoryWORKDIR /app is the conventionRecommended, not enforced
UserRun as non-root (USER node, USER 65532, etc.)Recommended — root containers are tolerated but flagged in build warnings
Image tagPin a specific tag (node:22-alpine) rather than :latestRecommended for reproducibility
CMD / ENTRYPOINTMust be setRequired to start the container

Environment variables

  • Build-time vars (e.g. NEXT_PUBLIC_API_URL, VITE_*) are passed to the build as build args. They land in image layers and are visible to anyone with registry pull access, so never pass a secret this way — see Runtime vs. build-time. To use one in your Dockerfile, declare it:

    ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL RUN npm run build

    Set them with ghayma env set --build-time, or tick Available at build time on the site’s Environment tab in the dashboard.

  • Database variables (DATABASE_URL, MONGODB_URI and the rest, for the databases the site is connected to) reach the build as secrets, never as build args: mount each one on the RUN that needs it, as above. Never re-declare one with ARG or ENV — that writes the password into the image. A Valkey’s REDIS_URL is not among them: it is runtime only (see Valkey).

  • Runtime vars (everything else) are injected as K8s container env at deploy time. Just read them at runtime with process.env.MY_VAR — no Dockerfile changes needed.

Validation warnings

The platform scans your Dockerfile and surfaces hints in the build log (warn-only — never blocks the build):

  • No FROM → the build will fail; pre-empt the cryptic error
  • No CMD / ENTRYPOINT → container has no startup command
  • No USER → runs as root (tolerated but not best practice)
  • :latest or untagged FROM → rebuilds may drift over time

The real verdict lives in the build log — fix what the build reports, redeploy.

Example: Next.js + Puppeteer (PDF generation)

This is the exact pattern Ghayma’s own aes-crm uses for HTML→PDF conversion. Multi-stage build, Alpine base, system chromium (musl-linked, runs on Alpine), points puppeteer at the system binary.

Dockerfile
# Stage 1 — install dependencies (skips puppeteer's own Chrome download) FROM node:22-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app ENV PUPPETEER_SKIP_DOWNLOAD=true ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true COPY package.json package-lock.json* ./ RUN npm ci # Stage 2 — build the app FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL RUN npm run build # Stage 3 — runtime (with system chromium) FROM node:22-alpine AS runner WORKDIR /app RUN apk add --no-cache \ chromium \ nss \ freetype \ harfbuzz \ ca-certificates \ ttf-freefont ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 ENV PUPPETEER_SKIP_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser ENV CHROME_BIN=/usr/bin/chromium-browser COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static RUN mkdir -p ./.next/cache && chown node:node ./.next/cache USER node EXPOSE 3000 CMD ["node", "server.js"]

In your Node code, launch puppeteer with the system binary:

import puppeteer from "puppeteer"; const browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, args: ["--no-sandbox", "--disable-setuid-sandbox"], });

Image size impact: roughly +150 MB for the chromium runtime libs. Acceptable for headless-browser use cases; not paid by projects that don’t need it (the auto-generated path doesn’t include chromium).

Example: minimal Node service

Dockerfile
FROM node:22-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM node:22-alpine AS runner WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./ USER node EXPOSE 3000 ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 CMD ["node", "dist/server.js"]

Falling back to auto-generation

If you decide you don’t need the custom Dockerfile any more:

  1. For one site: turn on “Always generate the Dockerfile for this site” in that site’s Settings
  2. For the whole project: flip the toggle off in Settings → Advanced
  3. (Optional) delete the Dockerfile from your repo, and clear dockerfile_path in the site’s Settings — removing the key from .ghayma.json leaves the saved path in place

The next deploy goes back to the auto-generated path. No data loss; both switches are purely build-time decisions.

Troubleshooting

“custom dockerfile_path … not found” — the path isn’t there inside your uploaded sources. Check for typos, and remember the path is relative to the app’s directory — your root_directory in a monorepo, the project root otherwise. If the key isn’t in .ghayma.json at all, the path is the one saved on the site from an earlier deploy: check the site’s Settings, and read the dockerfile_path= value in the build’s [build] site=… log line to see what the build actually used.

Build fails with npm error: spawn git ENOENT — the base image your Dockerfile starts from doesn’t ship git. Install it in the stage that runs the install (RUN apk add --no-cache git, or the apt-get equivalent). Private GitHub dependencies over git+ssh:// need more than that — the build has no access to your SSH keys, so pre-bundle those as npm tarballs or publish them to a registry.

DATABASE_URL is empty during the build, or Prisma says Environment variable not found — a secret reaches only a RUN that mounts it. Add --mount=type=secret,id=DATABASE_URL,env=DATABASE_URL to that step, and delete any ARG DATABASE_URL / ENV DATABASE_URL=… lines left from an older setup. A build log with no Build secrets available: line means the site is connected to no database. See Troubleshooting for the rest, a connection timeout (P1001) included.

“FROM uses :latest or untagged image” — informational warning, not blocking. Pin a tag (node:22-alpine) for reproducibility.

Container starts but ingress can’t reach it — check that your app listens on process.env.PORT and binds to 0.0.0.0, not localhost.