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.
- Open your project on the dashboard
- Settings → Advanced
- Flip the “Use custom Dockerfile” toggle on
- Next deploy uses your
Dockerfileinstead 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:
{
"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:
-
Explicit path in
.ghayma.json— set thedockerfile_pathfield, relative to the app’s directory (itsroot_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.
-
Convention — if no
dockerfile_pathis set, the platform looks for a literalDockerfileat your project root (or in yourroot_directoryif 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 deploySee 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:
| Field | Convention | Required? |
|---|---|---|
| Listening port | Read $PORT (defaults to 3000 if unset). EXPOSE 3000 is informational only — the platform routes based on the env var. | Required to receive traffic |
| Hostname | Bind to 0.0.0.0, not localhost or 127.0.0.1 — $HOSTNAME is set to 0.0.0.0 in the container | Required for K8s ingress to reach the pod |
| Working directory | WORKDIR /app is the convention | Recommended, not enforced |
| User | Run as non-root (USER node, USER 65532, etc.) | Recommended — root containers are tolerated but flagged in build warnings |
| Image tag | Pin a specific tag (node:22-alpine) rather than :latest | Recommended for reproducibility |
| CMD / ENTRYPOINT | Must be set | Required 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 buildSet 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_URIand the rest, for the databases the site is connected to) reach the build as secrets, never as build args: mount each one on theRUNthat needs it, as above. Never re-declare one withARGorENV— that writes the password into the image. A Valkey’sREDIS_URLis 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) :latestor untaggedFROM→ 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.
# 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
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:
- For one site: turn on “Always generate the Dockerfile for this site” in that site’s Settings
- For the whole project: flip the toggle off in Settings → Advanced
- (Optional) delete the
Dockerfilefrom your repo, and cleardockerfile_pathin the site’s Settings — removing the key from.ghayma.jsonleaves 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.