DocsApplications
How builds work
Three ways to turn a repo into an image: your own Dockerfile, automatic detection by Nixpacks, or a static site served by nginx.
- Options
- Dockerfile · Nixpacks · Railpack · Static site, per application
- Runs on
- Docker daemon (classic builder); Railpack via its own BuildKit
- Result
- Image <registry>/<project>/<app>:<id>
Which one should I pick?
| Dockerfile | Nixpacks | Railpack | Static site | |
|---|---|---|---|---|
| Setup | Write your own Dockerfile | Zero config | Zero config | Build command + output folder |
| Rebuilds | Docker layer cache | Layer cache only (no cache mounts, --no-cache) | BuildKit cache between deploys | Layer cache only |
| First build | Depends on the base image | Slow: base ± 350 MB + nix-env | 284 seconds (helper + BuildKit, once) | Fast: node alpine + nginx alpine |
| Later builds | Fast if layers are unchanged | Fast if layers are unchanged; slow when dependency files change | ±8 seconds — dependencies from cache | Fast if layers are unchanged |
| Image control | Full | Limited to nixpacks options | Limited to railpack options | None — nginx :80 |
| Remote server | Yes | Yes | No — host only | Yes |
| Best for | Production, lean images | Prototypes, repos without a Dockerfile | Repeated deploys without a Dockerfile | SPAs, landing pages, docs sites |
Dockerfile
Choose Build method → Dockerfile and set the Dockerfile path in the repo (default Dockerfile at the root). The build runs directly on the Docker daemon from the Git URL (POST /build?remote=…) — the daemon does the cloning, the API doesn't need git.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "server.js"]- Classic builder, not BuildKit: syntax like
RUN --mount,COPY --link, and heredocs isn't supported. - Layer caching applies between builds as long as the base image and early steps don't change — put
COPY package*.json+npm cibeforeCOPY . .. - Include
wgetorcurlin the image if you use a health check (alpine already ships busybox'swget).
Nixpacks
Choose Build method → Nixpacks for repos without a Dockerfile. There's no nixpacks binary on the host or in the API image — everything runs in a container:
- 01helperthe aoox-nixpacks image is built once (debian + git + the nixpacks CLI)
- 02clonea one-shot container runs git clone --depth 1 (credentials never leave the helper)
- 03plannixpacks build → writes .nixpacks/Dockerfile
- 04buildthe source is tarred → POST /build with the generated Dockerfile
- First build is slow: the nixpacks base image (± 350 MB) is pulled and the helper image is built once; aoox pins the nixpacks version.
- No dependency cache mounts —
--no-cacheis required because nixpacks' cache mounts need BuildKit. The Docker layer cache still applies: a redeploy with an unchanged source reuses every layer (seconds), while a change to dependency files rebuilds from that layer and downloads dependencies again. - The source tree is held in the API's memory during the build (without
.git) — very large repos are better off with a Dockerfile.
Steering Nixpacks
Nixpacks reads NIXPACKS_* variables. Set them via Build args:
NIXPACKS_NODE_VERSION=22
NIXPACKS_BUILD_CMD=npm run build
NIXPACKS_START_CMD=node server.jsAlternatively, put a nixpacks.toml in the repo — it's read automatically during plan.
Railpack
Choose Build method → Railpack for repos without a Dockerfile that get deployed repeatedly and where you want dependency caching to stick around. Unlike Nixpacks, Railpack executes its build plan through a long-lived moby/buildkit container with its own volume — dependency layers survive between deploys.
- 01helperthe aoox-railpack image is built once (docker CLI + git + the railpack binary)
- 02buildkitan aoox-buildkit container (moby/buildkit) starts once, with its own cache volume
- 03clonethe helper does git clone --depth 1, then railpack build --cache-key <project>/<app>
- 04cachedependency layers are kept in the BuildKit volume — the next deploy reuses them
- Images produced by Railpack listen on
$PORT(Railway's convention). aoox automatically fillsPORTwith the configured container port, unless the application already sets its ownPORTin env. - Build args are passed through as
--envto railpack — same as Nixpacks, they also affect its provider detection. - Cache is scoped per
project/application, so other applications never share layers by accident. The BuildKit volume is pruned automatically by the nightly maintenance job — Docker's own prune doesn't reach it.
Static site (nginx)
Choose Build method → Static site (nginx) for repos whose final output is just static files. aoox builds a two-stage Dockerfile: an (optional) build stage on node:22-alpine, then nginx:1.27-alpine serving the output folder on port 80 — the create form fills in Container port 80 for you when you pick Static site (you can change it, but nginx still listens on 80).
| Field | Details |
|---|---|
| Build command | E.g. npm ci && npm run build. Leave empty if the files already exist in the repo (no Node stage). |
| Output folder | Relative to the Root directory (repo root when empty): dist (default), build, out, or . for the whole repo. |
| SPA mode | On (default): unknown paths are served index.html (client-side routing). Off: a normal 404 for multi-page sites. |
FROM node:22-alpine AS build
WORKDIR /src
COPY . .
RUN npm ci && npm run build
FROM nginx:1.27-alpine
COPY --from=build /src/dist/ /usr/share/nginx/html/
COPY .aoox/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80- The container port must be
80. A health check path of/is enough (nginx alpine haswget). - The build runs through the same helper as Nixpacks (clone in a container, tar context) — private repos are safe, but there are no dependency cache mounts (unchanged layers are still reused).
- Runtime env is useless for static files, and Build args aren't yet passed to the static build stage. Build-time values (e.g.
VITE_API_URL) currently need to live in the repo (.env.production) or in the build command:VITE_API_URL=https://api.example.com npm run build. - The Node version is pinned to 22 by aoox; not yet changeable from the UI.
Root directory (monorepo)
A monorepo keeps several applications in one repository (for example apps/web and services/api). Set Root directory in the Git part of the application form to the folder you want built; empty means the repository root. It applies to every build type, pull request previews included.
| Build type | What is relative to the Root directory |
|---|---|
| Dockerfile | The build context is that folder (POST /build?remote=URL#branch:folder), and the Dockerfile path is relative to the same folder. |
| Nixpacks | Stack detection and the build run in that folder; .nixpacks/Dockerfile is written there. |
| Railpack | railpack build runs in that folder. |
| Static site | The build command runs in that folder and the Output folder is relative to it too. |
apps/web Root directory: apps/web (Nixpacks)
services/api Root directory: services/api (Dockerfile at services/api/Dockerfile)
apps/site Root directory: apps/site (Static site, output: .)- Files outside the folder are not available during the build. A Dockerfile in
apps/webcannotCOPY ../../packages/shared. Bundle shared packages first, or use a Dockerfile at the repository root with an empty Root directory. - Format: a relative folder, only letters, digits,
.,_,-separated by/; no..and no leading or trailing/. - A folder that does not exist on that branch fails the deployment with Root directory … was not found; the old container keeps running.
- Symbolic links in the repository that point outside the clone are rejected.
Want deploys only when that folder changed? See Webhook for monorepos.
Build args
The Build args field (Settings tab) uses KEY=VALUE per line and is passed as --build-arg. For Nixpacks and Railpack, the values also become env during plan/build. Not yet applied to static sites.
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build${{…}} references, and they end up baked into the pushed image. Don't put secrets here — use environment variables, which are applied at runtime.The resulting image
<registry.url>/<project-slug>/<app-slug>:<12-character deployment id>
# example: localhost:5000/toko/shop:a1b2c3d4e5f6- Old tags stay in the registry until deleted from the Registry page — that's what makes rollback possible. Delete + garbage collect to reclaim disk.
- On a remote server the image isn't pushed; it stays on that server's daemon with ref
aoox/<project>/<app>:<tag>.
Common pitfalls
| Symptom | Cause & fix |
|---|---|
| Build fails: unknown flag / --mount | BuildKit syntax in the Dockerfile. Replace it with a plain step. |
| Nixpacks: build hangs | Shouldn't happen — aoox forces --no-cache. Check the helper log on the Deploy tab. |
| Nixpacks picks the wrong Node/Python version | Set NIXPACKS_NODE_VERSION / NIXPACKS_PYTHON_VERSION in Build args, or engines in package.json. |
| Env is empty during the build | Env genuinely isn't available at build time. Use Build args (non-secret) or read env at runtime. |
| Railpack: "run on the aoox host only" | This application has a remote server. Switch to Dockerfile/Nixpacks, or remove its server placement. |
| Railpack: application doesn't accept connections | Check the application reads process.env.PORT instead of a hardcoded port. aoox fills it in automatically from the container port. |
Next steps
Found a mistake? Edit this page on GitHub ↗