Skip to content

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:

Terminal window
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 requirementgit push through the proxy needs the same Developer role as the REST authoring endpoints. If git push fails with a 403, 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):

Terminal window
# 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-askpass
export GIT_ASKPASS=/tmp/atelier-askpass
git clone http://x-access-token@<host>/api/git/my-app.git # git asks → token
cd my-app
# … write your app + a Dockerfile (its EXPOSE sets the served port) …
git add -A && git commit -m "initial app" && git push

c. 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 — main stays exactly at the commit you pushed (that’s also what deployed_source_sha reports). So you can edit and git push repeatedly with no git pull --rebase dance 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. A Dockerfile at the repo root → a single image named app. A Dockerfile in a subdirectory → one image per subdirectory, named after it (e.g. backend/Dockerfilebackend, frontend/Dockerfilefrontend). 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 frontend image in a multi-Dockerfile app): port is always 80 regardless of EXPOSE. Reached at http://<app>.atelier-apps.svc.cluster.local/ (single-image) or http://<app>-frontend.atelier-apps.svc.cluster.local/ (multi-image).
    • Other Services (e.g. backend/Dockerfile): port matches your EXPOSE. So EXPOSE 8000 + uvicorn --port 8000 is reached at http://<app>-backend.atelier-apps.svc.cluster.local:8000/, not :80.
  • A self-contained build context. Each image builds with its Dockerfile’s directory as the context, so COPY/ADD paths 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 builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV 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.0
EXPOSE 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 direct build infers services + ports from your Dockerfiles (EXPOSE) and builds from the commit you pushed — it does not write an atelier-spec.yaml, K8s manifests, or any other generated file back to your repo. The platform builds your code exactly as committed (so a frontend nginx config, 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 via GET /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:

  • /data and everything under it — survives git push rebuilds, 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 /data or 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 /data and 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 a USER directive or any securityContext. The gotcha: files your Dockerfile bakes in as root (e.g. a seed DB COPYd to /app) are fine to read, but anything the app must write belongs under /data. A SQLite file opened read-write from /data works; the same file left on the root-owned overlay (e.g. /app/app.db) fails at runtime with SQLITE_READONLY: attempt to write a readonly database.

Quick examples:

# Python/FastAPI app keeping a SQLite db on disk
ENV DATABASE_URL=sqlite:////data/app.db
# Node app writing uploaded files
ENV UPLOAD_DIR=/data/uploads

Databases & 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 ispart of the app; one unitits own app, fully decoupled
Deleting the appalso deletes the database and its volumeleaves the database untouched
On git pushapp rebuilds; the DB is not restartedDB untouched
Setupone file in the repotwo apps to create and wire together
Shared between appsnoyes

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: myapp
description: 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: 5
env:
- 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

PGDATA is not optional. A provisioned volume arrives formatted and so already contains a lost+found, and initdb refuses to initialise into a non-empty directory — the database crash-loops on first deploy. Point PGDATA at 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:

  1. Deploy the database via POST /api/apps/import:
    {
    "name": "myapp-db",
    "image": "postgres:16",
    "port": 5432,
    "volumes": [{ "mount_path": "/var/lib/postgresql/data", "size_gi": 5 }]
    }
    Then set its credentials as secrets (injected as env vars, never in the image or args): PUT /api/apps/myapp-db/secrets with POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_USER. Do this right after import — the postgres image refuses to start without POSTGRES_PASSWORD, so it crash-loops until the secret is set (the Health tab will say exactly that), then initialises on the redeploy.
  2. Point your app at it over the in-cluster service URL — read the DB app’s in_cluster_url from GET /api/apps/myapp-db, then set a secret on the application app:
    DATABASE_URL=postgres://user:pass@myapp-db.<namespace>.svc.cluster.local:5432/<db>
    (The portal hostname does not resolve in-cluster — use the service URL.)
  3. 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-db is conventional, but note it then owns that name: the app myapp can no longer add an in-app service called db (pattern A), because that would generate resources named myapp-db too. 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.)