- every deployment is built by a Job in
atoll-builds, running the builder image; - every Ktor module becomes a Deployment + Service + Ingress + HPA in
atoll-apps, with its env vars in a Secret; - uploaded sources, web apps and downloadable artifacts are kept in object storage (S3-compatible);
- deployment records, accounts, logs and build callbacks are kept in Postgres.
deploy/helm/atoll.
- Production: follow the self-hosting guide. It installs the chart with a helmfile, next to Traefik, cert-manager, a replicated Postgres (CloudNativePG), SeaweedFS, zot and monitoring.
- Development: this page.
deploy/k8srenders the same chart through kustomize withdeploy/k8s/values.yaml: bundled single-pod Postgres, SeaweedFS and zot, and ingress-nginx on a local kind cluster. ingress-nginx no longer gets fixes since March 2026, so never expose a cluster set up this way.
Development cluster with kind
Prerequisites
- Docker with at least 8 GB of memory, a recent kind,
kubectlandhelm(kustomize calls it to render the chart). - Builds request 2 CPUs and 6 GiB by default. On a small machine lower
builds.cpuandbuilds.memoryindeploy/k8s/values.yaml.
1. Create the cluster
The cluster needs ports 80/443 for the ingress controller and a containerd registry config directory, so nodes can pull the images the platform builds:2. Let nodes pull from the bundled registry
Build pods push app images to the bundled zot registry by its cluster DNS name,atoll-registry.atoll.svc.cluster.local:5000. The container runtime on the nodes does not use cluster DNS, so
every node gets a containerd mirror for that name pointing at the registry’s NodePort (30500):
hosts.toml on every pull, no restart is needed. Node pulls arrive at the registry from the node’s
pod network gateway; registry.bundled.nodeAccess.allowedCIDRs in deploy/k8s/values.yaml admits kindnet’s
default (10.244.0.1/32). The chart generates the registry users and the push and pull Secrets itself.
3. Ingress controller and metrics-server
4. Build and load the images
From the repository root:kind load puts the images straight into the node’s containerd. The tag is local, not latest, because
Kubernetes always pulls latest and these images exist nowhere else. The builder image is large (JDK and Android
SDK); loading it takes a while.
5. Secrets and the platform
ATOLL_ENV_KEYS encrypts project env vars; keep it, or stored values become unreadable. ATOLL_RUNNERS_SEAL_KEY
encrypts the env vars of waiting build runner jobs; changing it only fails the jobs queued at that moment. s3.json is SeaweedFS’
list of S3 users and must hold the same keys as access-key and secret-key. The load restrictor is needed
because the chart lives outside deploy/k8s. Add your own kustomize overlays on top of deploy/k8s as usual;
every chart value is documented in deploy/helm/atoll/values.yaml.
6. Sign in and deploy
The server has a database, so accounts are on. Openhttp://atoll.localhost, sign up, and open the verification
link from the server log (no SMTP server is configured, and the panel is on http, so the link is logged):
*.localhost to 127.0.0.1, so with these values:
Apps live one level below the API host so the panel’s session cookie never reaches them.
<module> is the Gradle
path as a DNS label: :apps:server becomes apps-server, the root project root. Names longer than 17 characters,
and modules whose names would collide, are shortened to 10 characters plus a hash, e.g. my-very-lo-1a2b3c. The
primary Ktor module is the one named by the Gradle property atoll.primary (e.g. atoll.primary=:apps:api in
gradle.properties), otherwise the shallowest one, ties broken by path. The primary web app is chosen the same
way, without an override.
The wildcard Ingress *.apps.atoll.localhost (atoll-sites) sends every app host to atoll-server, which serves
web apps by Host header. Exact-host Ingresses of Ktor modules take precedence over it (ingress-nginx prefers exact
hosts; with Traefik the chart gives the wildcard the lowest priority).
To debug a build: kubectl -n atoll-builds get jobs,pods and
kubectl -n atoll-builds logs job/atoll-build-<id> -c build (or -c publish). Finished Jobs are deleted after an
hour.
How a build runs
- The replica that receives the upload checks the project’s storage quota, stores the source in object storage,
records itself as the deployment’s owner and creates
Job atoll-build-<id>with two random tokens, one per container. - The init container
buildruns the user’s Gradle build. It downloads the source fromATOLL_INTERNAL_URL/internal/builds/<id>/source, runs Gradle, uploads the project model, web apps and artifacts, and leaves one image layer per Ktor module in a shared emptyDir. Its token can do nothing else except report a failure. - The container
publishpushes those layers to the registry with crane, then reports the images and the build’s success. Onlypublishholds the registry credentials and the token that may report images and success; it runs as another uid with a read-only root filesystem, gets none of the project’s build env vars, and only hands the layer files to crane as data. - The owning replica tails both containers’ logs into the deployment log, rolls out every Ktor module and marks the deployment ready (or partial, if some targets failed while others went live). Rollouts of one project never overlap, even across replicas (a Postgres advisory lock).
ATOLL_INTERNAL_PORT, 8081), which serves the build
protocol and nothing else; /internal answers 404 on the API port. Build pods can’t reach the API port at all, so
untrusted build code can never talk to the API, e.g. with a forged X-Forwarded-For that the API trusts from the
ingress controller. Calls may land on any replica: callbacks are stored in Postgres and files in object storage.
Uploads are held to limits, so a small archive can’t unpack into gigabytes on the server’s disk. A build may upload
ATOLL_BUILD_MAX_OUTPUT_BYTES in total (web apps counted unpacked; a retried upload replaces the earlier one), no
single file may exceed ATOLL_BUILD_MAX_FILE_BYTES, and a web app may have at most ATOLL_BUILD_MAX_SITE_FILES
entries. Bytes are counted as they arrive and unpack. An upload past a limit is answered with 413 and fails the
build with a message naming the setting, which shows in the deployment log. The server’s scratch volume must hold
what builds upload at once (server.scratchSize in the chart).
The owner renews a lease on its deployments every 10 seconds. If a replica dies, another one marks its unfinished
deployments FAILED after 60 seconds (“Interrupted because the server running it stopped”).
Ktor images are pushed as <ATOLL_REGISTRY_PUSH>/atoll/<project>/<module>:<deployment id>. publish reports each
image with the digest crane computed for what it pushed (...:<deployment id>@sha256:..., never looked up by tag),
and app Deployments reference that digest (imagePullPolicy: IfNotPresent), so a later push to the same tag never
changes a running app; promotions roll out the same digest again. Deployments from before digest pinning can only
be promoted with --rebuild. The server only accepts digest-pinned images under the build’s own project and tagged
with its deployment id, and rejects the same image reported for two modules. It also refuses builder images older
than itself, with a message naming ATOLL_BUILDER_IMAGE: the server and the builder image are upgraded together.
App resources
Each Ktor module gets a Deployment, Service, HorizontalPodAutoscaler and Ingress named<project>--<module>,
labelled atoll.app/project=<project> and atoll.app/module=<module> (the selector of the Deployment and Service)
and annotated with the Gradle path (atoll.app/module-path). The primary module also gets an Ingress named
<project> for the bare host; it is switched to a new primary only after that module’s rollout completed. Rollouts
are watched per module, so a module that crashes fails only its own target.
The module’s runtime env vars live in a Secret <project>--<module>-env, owned by its Deployment, and reach the pods
through envFrom; the Deployment spec holds no values. The pod template carries a checksum of the env vars
(atoll.app/env-checksum), so changing only an env var still rolls the pods. Values are passed as they are (no
$(VAR) expansion). Apps deployed before this keep their inline env until their next deployment.
After all targets of a deployment are processed, the platform deletes the resources of Ktor modules that are no longer in
the project (their env Secret goes with the Deployment). Modules that are still in the project but failed to build
or roll out keep their previous instance, and so does the module the bare host still routes to until a new primary
module takes it over. A deployment whose build finishes after a newer deployment of the same project was already
rolled out deploys no Ktor modules (they are reported as skipped) and deletes nothing.
Scaling
Each production Ktor module has a size, replica bounds and scale to zero, set on the panel’s Scaling tab or withPUT /api/projects/<project>/scaling (developers; members read them). Changes are applied to the running app at
once; a new size rolls its pods. Previews always run xs with one replica.
The size is both the requests and the limits of the app container, and the JVM gets
JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75.0 unless the project sets JAVA_TOOL_OPTIONS itself. Billing counts the
requests of running app pods once a minute, so a module costs its size times its running replicas, and nothing
while it is scaled to zero. The HPA keeps the replicas between the bounds (70% CPU target). Free orgs run up to s
and ATOLL_SCALING_FREE_MAX_REPLICAS replicas, and scale to zero by default; pay-as-you-go orgs run any size up to
ATOLL_SCALING_MAX_REPLICAS. Without billing there are no plan limits.
Scale to zero uses KEDA and its HTTP add-on. For a
module that scales to zero, the platform creates an HTTPScaledObject instead of the HPA, and an ExternalName Service
w--<project>--<module> pointing at the add-on’s interceptor; the module’s Ingresses (and the bare host’s) lead to
that Service. The interceptor holds each request until a replica is ready, so the first request after an idle
period waits for the app to start (a few seconds for a small JVM app). After ATOLL_SCALING_IDLE_MINUTES without
requests KEDA scales the module back to zero. A deploy starts one replica so the rollout shows the new version
works; it goes back to zero once idle.
ATOLL_SCALING_SCALE_TO_ZERO=off, or while the cluster has no HTTPScaledObject API
(checked once a minute), modules keep their HPA and their Ingresses lead straight to their Service; the panel then
says scale to zero isn’t available and the API refuses to turn it on. Suspended projects never wake: their
HTTPScaledObject is removed while suspended and comes back when they resume.
Custom domains lead to the module’s own Service, not through the interceptor, which only knows the module’s
hosts. A request on a custom domain alone does not wake a module that scaled to zero (it gets a 503 until the
module runs again), so keep scale to zero off for modules that are mainly reached on a custom domain.
The self-hosted helmfile installs KEDA and the add-on in the keda namespace (scaleToZero.enabled); the chart
grants the server httpscaledobjects in the apps namespace and lets the interceptor reach app pods. Traefik must
follow ExternalName Services (providers.kubernetesIngress.allowExternalNameServices, already set for custom
domains).
Upgrading from single-app deployments
Earlier versions deployed one Ktor app per project, with every resource named<project> and a selector without a
module label. A selector cannot be changed in place, so the next deployment creates the new <project>--<module>
resources next to the old ones. Once the primary module’s rollout completed and its Ingress <project> took over
the bare host, the old Deployment, Service and HPA named <project> are deleted. If the primary module fails, the
old app keeps serving the bare host until a later deployment succeeds. Before the first module rolls out, the platform
limits the old Service to the old app’s pods (app.kubernetes.io/name=<project>) and deletes the old HPA, which
would count the new pods too.
Project names containing -- or ending in -web are no longer accepted, because those hosts now belong to modules
and web apps of other projects. Apps deployed under such names can’t be redeployed or pruned by the platform and shadow
the new names. Find and delete them by hand:
Isolation
Builds run arbitrary Gradle code and apps run arbitrary Ktor code, so both are locked down:- Pods have no service account token, run as non-root (uid 10001 for
build, 10002 forpublish) without privilege escalation, with all capabilities dropped,RuntimeDefaultseccomp, CPU and memory limits, and only emptyDir volumes plus read-only Secrets (build env vars forbuild, registry push credentials forpublish). Build Jobs have a deadline andbackoffLimit: 0. Both workload namespaces enforce therestrictedPod Security Standard. - The
atoll-buildsNetworkPolicy blocks all inbound traffic and allows outbound traffic only to DNS, the server’s internal port, the bundled registry and port 443 on public addresses (Gradle and Maven repositories). App pods accept traffic only from the ingress controller and reach only public addresses. NetworkPolicies need a CNI that enforces them (kindnet in recent kind releases, k3s’ built-in controller, Calico, Cilium); otherwise they are silently ignored. - The server holds Secret rights only for what it manages: one env Secret per build in
atoll-buildsand one per app inatoll-apps(it never lists Secrets there), and TLS Secrets of custom domains (delete only).
- Run builds on their own nodes. Label and taint a node pool for builds and set
builds.nodeSelectorandbuilds.tolerations(ATOLL_BUILD_NODE_SELECTOR,ATOLL_BUILD_TOLERATIONS), so build pods never share a node with the server, Postgres or object storage. See Keep builds on their own nodes. - Sandbox builds with gVisor. Install
runscon the build nodes, register it with containerd and setbuilds.runtimeClass(ATOLL_BUILD_RUNTIME_CLASS=gvisor); the chart can create the RuntimeClass. See gVisor for builds. Builds are noticeably slower under gVisor, mostly from file system overhead. On kind, installrunscinside each node container (docker exec), add the runtime to/etc/containerd/config.tomland restart containerd in the node.
ATOLL_RUNTIME=local), builds and apps run as processes on the server’s own host.
That is for local development only: with a database (accounts on) the server refuses to start in local mode unless
ATOLL_ALLOW_LOCAL_BUILDS=true.
Configuration
With the chart you set these through values; Configuration maps each value to its variable. The database:
The server applies schema migrations on start.
Object storage:
The Kubernetes runtime:
Scaling (see Scaling):
A missing push secret, or one that is not of type
kubernetes.io/dockerconfigjson with a .dockerconfigjson key,
fails a build right away. App Deployments get a new pull secret on their next rollout; redeploy every Ktor app once
after setting ATOLL_REGISTRY_PULL_SECRET if the registry now requires a login. See
Registry for the self-hosted setup.
Regions
A region is a Kubernetes cluster that runs Ktor apps. Any cluster works, whoever hosts it. The cluster the server runs in is the built-in home region, so an install without regions works as before: every project runs at home. Platform admins (ATOLL_AUTH_ADMIN_EMAILS) add more regions in the panel (Admin, Regions) or through
/api/admin/regions, each with:
- an id (
eu-west) and a name; - an apps domain: the region’s Ktor apps are served on
<project>.<domain>and<project>--<module>.<domain>; - a kubeconfig with inline credentials (a service account token is best). It is stored encrypted with
ATOLL_REGIONS_SEAL_KEY; kubeconfigs that read files or run credential plugins (exec,auth-provider) are refused, since they would run on the server; - optionally a registry address the region’s nodes pull app images from, and a Prometheus URL its metrics are read from;
- optionally a country (a two-letter ISO 3166-1 code such as
de, oreu), shown as a flag next to the region in the panel. Without one the panel guesses it from the region’s name and id.
How it works
- Builds stay at home. Build Jobs run in the home cluster and push to its registry; only the rollout goes to the project’s region. Every region must be able to pull from that registry (see below).
- Apps run in the project’s region. The server wraps one Kubernetes runtime per region (
RegionalAppRuntime): rollouts, prunes, app logs, suspension when billing stops a project, preview retirement and project deletion are sent to the cluster of the project’s region, with the region’s apps domain and registry address. - Web apps stay at home. The server itself serves web targets (
<project>-web.<home apps domain>), whatever the project’s region; so do downloads of Android and desktop artifacts. - Custom domains of Ktor apps get their Ingress, ExternalName Service and certificate in the region’s cluster, and
must CNAME to
cname.<region domain>; web domains keep pointing at the home cluster. A project with custom domains can’t move; remove them first.
Moving a project
Admins of a project’s org change its region on the project’s Scaling tab (orPUT /api/projects/<name>/region).
The platform records the new region, then promotes the live production deployment: its images roll out again in the new
region (rebuilt if they predate digest-pinned images). The old region keeps serving until that deployment is live,
then its apps there are removed. The app URLs change to the new region’s domain. A project with nothing live just
changes region. Moves are refused while a deployment runs. Previews of the project are removed from the old region
with its other apps; they are deployed again by the next push to their pull request.
Setting up a region cluster
- Create the apps namespace (
atoll-apps, orATOLL_APPS_NAMESPACE) with an ingress controller of the same class as the home cluster (ATOLL_INGRESS_CLASS), metrics-server for the autoscalers, and cert-manager with theATOLL_DOMAINS_CLUSTER_ISSUERif projects there use custom domains. - Create a ServiceAccount with the rights the chart gives the server in its own apps namespace
(
deploy/helm/atoll/templates/rbac.yaml: deployments, services, ingresses, HPAs, secrets, pods andpods/log), and a kubeconfig with its token. For custom domains it also needs ingresses, services and secrets in the domains namespace (atoll-domains) and read access to cert-manager Certificates there. - Make the home registry pullable from the region’s nodes: expose it (for example on a public hostname with TLS and
the chart’s pull credentials) and set the region’s registry to that address if it differs from
ATOLL_REGISTRY_PULL. Copy the pull secret (ATOLL_REGISTRY_PULL_SECRET,atoll-registry-pullwith the chart) into the region’s apps namespace. An external registry that every cluster reaches avoids both steps. - Point a wildcard DNS record
*.<region domain>(andcname.<region domain>for custom domains) at the region’s ingress. TLS for app hosts comes from the ingress controller’s default certificate, as at home, so give the region’s controller a wildcard certificate for its domain. - Add the region in the panel and use Test connection: it checks that the API answers, the apps namespace exists
and the credentials may create Deployments, Services, Ingresses and Secrets there. It also checks storage (the
atoll-storagenamespace, the server’s rights there and CloudNativePG); without it the region runs apps but no stores, see selfhost/storage.md. - Install the firewall edge: the chart with
mode: edge(example:deploy/helm/atoll/ci/edge-values.yaml) putsatoll-edgeinto the region and gives the home server its rights there. A region without it serves its apps unfiltered, see selfhost/firewall.md.
ATOLL_REGIONS_SEAL_KEY on every server replica (with the chart, through a Secret in
server.extraEnvFromSecrets). Without it the server starts and runs everything at home, but refuses to store a
kubeconfig. Regions need ATOLL_RUNTIME=kubernetes: only then do builds produce images another cluster can run.
e2e/regions.sh sets up two k3d clusters on one docker network this way and deploys a sample into the second.
Serving web apps through a CDN
Web apps are static files, so a CDN can serve most requests. Point the CDN (Cloudflare, CloudFront, …) at the ingress for*-web.<apps domain> hosts and keep the Host header. The platform’s responses tell it what to keep:
Limitations
- A deployment runs on the replica that received it. When that replica stops (a crash, or a rolling update of the platform itself), its unfinished deployments fail and must be deployed again; another replica does not take them over.
- The bundled Postgres, SeaweedFS and zot run as single pods without backups, fine for development. Use the self-hosting setup or managed services in production.
- Every build pushes with the same registry credentials, so a malicious build could push to another project’s repository. Apps only run digests their own builds reported, but use a registry with per-project credentials before running untrusted builds for many tenants (Registry).
- App pods only accept traffic from the ingress controller, so Ktor modules of one project reach each other through their public hosts, not cluster DNS.