Bring your own code
You’re using this path when: you write the code yourself and want Atelier to build and host it. No LLM in the loop — the platform builds your Dockerfile verbatim and auto-generates the K8s manifests.
This is the only build path: the platform does not author code. If the user gives you an idea rather than code, you write the code, then land it here.
Flow
a. Scaffold an empty app with a git repo and a push-to-build webhook:
curl -s "$ATELIER_API_URL/api/apps/scaffold" \ -H "Authorization: Bearer $ATELIER_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"name":"my-app","description":"optional"}'# → { "name": "my-app", "status": "scaffolded", "build_mode": "direct",# "clone_url": "...", "clone_url_in_cluster": "..." }b. Clone, add code + a Dockerfile, push. Authenticate git with the same
ATELIER_API_TOKEN as the password (username can be anything) — it goes
through the platform’s git proxy, so no separate Gitea credentials are needed.
Use clone_url_in_cluster if you’re running inside the cluster, else clone_url.
Token role requirement —
git pushthrough the proxy needs the same Developer role as the REST authoring endpoints. Ifgit pushfails with a403, the token’s role is too low (Viewer can clone/fetch but not push). Mint a Developer-role token in Settings → System → API Tokens; the same token works for the REST API and for git.
Supply the token out of band rather than embedding it in the remote URL (URL-embedded credentials leak via shell history / process listings):
# A throwaway askpass helper feeds the token to git without it touching the URL.printf '#!/bin/sh\necho "$ATELIER_API_TOKEN"' > /tmp/atelier-askpass && chmod +x /tmp/atelier-askpassexport GIT_ASKPASS=/tmp/atelier-askpass
git clone http://x-access-token@<host>/api/git/my-app.git # git asks → tokencd my-app# … write your app + a Dockerfile (its EXPOSE sets the served port) …git add -A && git commit -m "initial app" && git pushc. The push triggers a direct build (build-from-source, no LLM) and
deploys it. Watch progress on the event stream (see SKILL.md → Watching build
progress); the app goes scaffolded → building → running.
Iterating is just push again. The build no longer commits anything back to your repo —
mainstays exactly at the commit you pushed (that’s also whatdeployed_source_shareports). So you can edit andgit pushrepeatedly with nogit pull --rebasedance between builds.
What a direct build needs in the repo
A direct build never calls an LLM — it builds from your committed Dockerfiles
(no generation step), so the repo must be self-sufficient:
- At least one
Dockerfile. ADockerfileat the repo root → a single image namedapp. ADockerfilein a subdirectory → one image per subdirectory, named after it (e.g.backend/Dockerfile→backend,frontend/Dockerfile→frontend). No Dockerfile = build fails. EXPOSE <port>in each Dockerfile — that’s the port the container listens on. Without it, 8080. The matching in-cluster Service depends on whether this image is ingress-routed or a backend:- Ingress-routed Service (single-Dockerfile apps, or the
frontendimage in a multi-Dockerfile app): port is always 80 regardless ofEXPOSE. Reached athttp://<app>.atelier-apps.svc.cluster.local/(single-image) orhttp://<app>-frontend.atelier-apps.svc.cluster.local/(multi-image). - Other Services (e.g.
backend/Dockerfile): port matches yourEXPOSE. SoEXPOSE 8000+uvicorn --port 8000is reached athttp://<app>-backend.atelier-apps.svc.cluster.local:8000/, not :80.
- Ingress-routed Service (single-Dockerfile apps, or the
- A self-contained build context. Each image builds with its Dockerfile’s
directory as the context, so
COPY/ADDpaths must be relative to that directory and everything needed must be committed (mind.dockerignore). - Nothing else. Atelier auto-generates the Kubernetes manifests (Deployment + Service + Ingress) and injects the app’s secrets/config — you do not write any k8s YAML. App secrets set via the Secrets tab/API are available as env vars in the containers.
Known-good Next.js standalone Dockerfile
The single most common direct-build failure is a Next.js output: 'standalone'
Dockerfile that COPY --from=builder /app/public ./public when the project has
no public/ directory. BuildKit aborts with "/app/public": not found and no
LLM step can fix it for you. The one prerequisite for the Dockerfile below:
make sure a public/ directory exists in your repo — Next does not create
one, so if your project has no static assets, commit an empty placeholder
(mkdir -p public && touch public/.gitkeep) or the COPY … /app/public line
fails. (A COPY source that doesn’t exist is a hard error on every BuildKit
version — there is no “trailing-dot no-op” trick.)
# next.config.js must include: module.exports = { output: 'standalone' }FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:20-alpineWORKDIR /appENV NODE_ENV=productionENV PORT=3000# Next's standalone server binds to $HOSTNAME. In a container this MUST be# 0.0.0.0 — if it defaults to localhost, the in-cluster Service can't reach# the pod and the app comes up "unhealthy" with no obvious error.ENV HOSTNAME=0.0.0.0EXPOSE 3000
# .next/standalone is self-contained — server.js + node_modules already inside.COPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/static# Requires public/ to exist in the repo (see prerequisite above).COPY --from=builder /app/public ./public
CMD ["node", "server.js"]Native modules (better-sqlite3, sharp, bcrypt, …): output: 'standalone'
traces the JS it can see but can miss dynamically-loaded .node binaries, so
the runner 500s at startup with a “module not found”/“cannot open shared object”
error even though the build succeeded. Copy the package dir (and its native
deps) into the runner explicitly, e.g.
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3.
Also note node:20-alpine is musl — native addons need apk add --no-cache python3 make g++ in the builder (or use a glibc base like node:20-bookworm-slim),
otherwise the prebuilt binary mismatches at runtime.
Caveat: server-rendered Next.js apps emit root-absolute URLs (/_next/...,
href="/about"). They work in-cluster (http://<app>.atelier-apps.svc.cluster.local/)
and via Public Access (a dedicated hostname served at /), but the portal
proxy at /apps/<name> strips the prefix and may break root-absolute SSR
apps. Set up Public Access for any SSR / Vite / Astro app you intend to
demo from the portal.
Your repo is left untouched. A
directbuild infers services + ports from your Dockerfiles (EXPOSE) and builds from the commit you pushed — it does not write anatelier-spec.yaml, K8s manifests, or any other generated file back to your repo. The platform builds your code exactly as committed (so a frontendnginxconfig, SSR routing, etc. is used as-is — see the Public Access note above for serving such apps behind the portal proxy). The generated manifests live on the build record, retrievable viaGET /api/apps/{name}/builds/{id}.
Persistent storage at /data
Every app gets a per-app PersistentVolumeClaim auto-mounted at /data
inside the container (1 GiB by default; configurable per app in the Atelier UI
under Resources). Write any state your app needs to survive restarts to that
path — a SQLite file, uploaded user files, a cache, log archive, whatever fits.
What’s persistent:
/dataand everything under it — survivesgit pushrebuilds, direct re-deploys, pod restarts, node reboots. Backed by Longhorn replicated storage.
What’s ephemeral (gone on every pod roll — do not put state here):
- The container’s working directory and any path your Dockerfile writes to
during the build (e.g.
/app,/usr/src/app). /tmp,/var/..., container-overlay paths.- The cwd of the process you launch — unless you explicitly
cd /dataor configure your runtime to write there.
You do not declare the volume or mount in your Dockerfile, in any YAML, or
anywhere else — Atelier’s manifest generator wires it in automatically. Just
write to /data from your code.
Apps run as a non-root user (uid 1000). Atelier mounts
/dataand chowns it to uid 1000 (via an init container) so your process can read and write it out of the box — you do not need aUSERdirective or anysecurityContext. The gotcha: files your Dockerfile bakes in as root (e.g. a seed DBCOPYd to/app) are fine to read, but anything the app must write belongs under/data. A SQLite file opened read-write from/dataworks; the same file left on the root-owned overlay (e.g./app/app.db) fails at runtime withSQLITE_READONLY: attempt to write a readonly database.
Quick examples:
# Python/FastAPI app keeping a SQLite db on diskENV DATABASE_URL=sqlite:////data/app.db# Node app writing uploaded filesENV UPLOAD_DIR=/data/uploadsDatabases & stateful services (Postgres, MySQL, Redis…)
First, do you need one? For modest needs, a SQLite file on /data (above)
is the simplest answer — no second app, no networking, backed up with the app.
Reach for a real database only when you actually need concurrent writers,
multiple readers across services, or a feature SQLite can’t give you.
When you do need Postgres/MySQL/Redis, there are two patterns. Pick deliberately — they differ in one thing that matters: what happens to the database when the app goes away.
A — In-app service (atelier-spec.yaml) | B — Separate app (Deploy an Image) | |
|---|---|---|
| The database is | part of the app; one unit | its own app, fully decoupled |
| Deleting the app | also deletes the database and its volume | leaves the database untouched |
On git push | app rebuilds; the DB is not restarted | DB untouched |
| Setup | one file in the repo | two apps to create and wire together |
| Shared between apps | no | yes |
Choose A when the database is an implementation detail of one app and the two belong together — an app you install, ship, or hand to someone as a whole. It’s one file and there’s nothing to wire up.
Choose B when the data must outlive the app, when more than one app uses the database, or when you’re actively churning app code and want the database plainly out of the blast radius. If you’re unsure, or the data is precious, choose B — its whole advantage is that a mistake with the app can’t take the data with it.
Either way, never hand-write a Dockerfile FROM postgres. Run the upstream
image.
Pattern A — a database inside your app
Declare it in atelier-spec.yaml. See atelier-spec.md
for the full contract; the short version:
name: myappdescription: App with a database.services: - name: app port: 3001 dockerfile: Dockerfile - name: db port: 5432 image: postgres:16-alpine # never built — run as-is volumes: - mount_path: /var/lib/postgresql/data size_gi: 5env: - name: POSTGRES_USER default: myapp - name: POSTGRES_DB default: myapp - name: PGHOST default: "${APP}-db" # ${APP} → the app's name at deploy time - name: PGDATA default: /var/lib/postgresql/data/pgdata # a SUBDIRECTORY — see below - name: POSTGRES_PASSWORD secret: true generate: true # minted for you; nobody types it
PGDATAis not optional. A provisioned volume arrives formatted and so already contains alost+found, andinitdbrefuses to initialise into a non-empty directory — the database crash-loops on first deploy. PointPGDATAat a subdirectory of the mount. Standard for Postgres on Kubernetes; MySQL and MongoDB have the same trap.
Your app reaches the database at {app}-db:5432 in-cluster. Every env var
above is visible to both containers, which is how the database initialises with
the same password your app connects with.
Pattern B — a Postgres companion app
Recipe:
- Deploy the database via
POST /api/apps/import:Then set its credentials as secrets (injected as env vars, never in the image or args):{"name": "myapp-db","image": "postgres:16","port": 5432,"volumes": [{ "mount_path": "/var/lib/postgresql/data", "size_gi": 5 }]}PUT /api/apps/myapp-db/secretswithPOSTGRES_PASSWORD,POSTGRES_DB,POSTGRES_USER. Do this right after import — thepostgresimage refuses to start withoutPOSTGRES_PASSWORD, so it crash-loops until the secret is set (the Health tab will say exactly that), then initialises on the redeploy. - Point your app at it over the in-cluster service URL — read the DB
app’s
in_cluster_urlfromGET /api/apps/myapp-db, then set a secret on the application app:(The portal hostname does not resolve in-cluster — use the service URL.)DATABASE_URL=postgres://user:pass@myapp-db.<namespace>.svc.cluster.local:5432/<db> - Your app’s own frontend/backend can still be a single multi-Dockerfile app; only the stateful dependency gets its own app.
Naming. Calling the DB app
myapp-dbis conventional, but note it then owns that name: the appmyappcan no longer add an in-app service calleddb(pattern A), because that would generate resources namedmyapp-dbtoo. Atelier refuses the deploy rather than overwrite. Don’t mix the patterns for the same app.
Whichever pattern you pick: make the app tolerate the DB not being ready.
Kubernetes has no depends_on, so your app can start before the database is
accepting connections. Retry the connection on startup rather than assuming
the DB is up — otherwise it crash-loops on first deploy. It will recover on its
own once the DB is ready, but a short retry loop turns an alarming crash-loop
into a clean start. (If it does crash-loop, the app’s Health tab shows the
reason and the previous-container logs.)