axhub.yaml
Canonical deploy manifest schema from the current ax-hub-backend manifest domain
axhub.yaml is the deploy contract file you commit to the repository. Apps created from a template already ship with this file set up, so you rarely need to touch it.
Look up the exact fields on this page when you want to pin the build commands yourself, declare an app-owned database or storage, or pick the compose entry service. Deploys work without the file too. AxHub inspects the repository and decides the build method on its own, and that order is on this page as well. For the step-by-step of wiring each feature into your app, see the Install the SDK.
Minimal example
version: axhub/v1
name: hello-axhub
root: web # only when the app lives in a subfolder (monorepo)
runtime:
port: 3000
health_path: /
build:
strategy: auto
framework: node
deploy_method: docker
dockerfile: Dockerfile
database:
engine: postgres
storage:
enabled: true
env:
required:
- name: OPENAI_API_KEY
scope: runtime
optional:
- name: NEXT_PUBLIC_API_URL
scope: build
ci:
commands:
- npm test
timeout: 300All fields are optional. Unset values fall back to DB settings or resolver defaults.
root — when the app lives in a subfolder
If one repository holds several projects and the thing you deploy is in a subfolder (say web/), add one line to the axhub.yaml at the repository root.
root: webThat one line makes the folder behave as the app's top level. You can drop the workaround Dockerfile you kept at the root and get automatic detection and Dockerfile generation instead.
What becomes relative to root
- Detection of Dockerfiles, compose files, and framework markers (
package.jsonand friends) - Generated Dockerfiles and the build context (what gets copied, which
.dockerignoreapplies) - The other path fields in this file —
build.dockerfile,build.compose_file,build.static_output_dir,metadata.icon - Where CI commands and static site builds run
So root: web plus dockerfile: docker/prod.Dockerfile actually reads web/docker/prod.Dockerfile. You never have to remember which fields are repo-relative and which are app-relative.
Rules
| What you write | Result |
|---|---|
web, ./web, web/ | All treated as web |
web/app | More than one level deep is fine |
., ./ | Same as not declaring it (repository root) |
/web, ../other | Fails before the deploy — only relative paths inside the repo |
| A folder with no files in it | Fails before the deploy |
Config is read only from the axhub.yaml at the repository root. A web/axhub.yaml sitting alongside is ignored, with one warning line on the deploy. Conversely, if there's no file at the root and only web/axhub.yaml exists, that counts as no root declaration at all.
- With
rootdeclared, a Dockerfile at the repository root is ignored — root is the app's boundary - It's a repository setting rather than a console one, so a single push applies it (no reconnecting, no console steps)
- Security scanning still covers the whole repository — the scope isn't narrowed
- Apps without a
rootdeclaration behave exactly as they did before this existed
Files and precedence
| Item | Current contract |
|---|---|
| canonical filename | axhub.yaml |
| max size | 8 KiB |
| priority | manifest > DB AppSpecData > resolver default |
| deploy resolution | manifest → Dockerfile → Compose → Railpack auto-detection |
How the build method is decided
AxHub decides how to build by looking at which files exist at the repository root. It checks in the order below, top to bottom, and stops at the first match.
Here is exactly what each step looks at.
axhub.yaml— if present, this manifest wins. Abuild.dockerfileoverride andstrategy: pinned+startpinned execution take effect at this step.Dockerfile— if present at the root, the app is built as a single image.- Compose file — searched in the order
docker-compose.yml·docker-compose.yaml·compose.yaml·compose.yml. - Railpack auto-detection — if none of the above exist, the framework is inferred from marker files:
package.json→node ·go.mod→go ·requirements.txt/pyproject.toml→python ·Gemfile→ruby ·pom.xml/Gradle files→java·kotlin ·Cargo.toml→rust.- For languages other than Node · Python · Go · Ruby · Java (Rust, etc.), the auto-generated Dockerfile uses an image without that toolchain, so the build can fail. Put a Dockerfile in the repository for these apps.
If none of the four match, no build method can be found and the deploy fails. To pin the build/run commands yourself instead of leaving them to guesswork, put an axhub.yaml in the repository — start from the minimal example.
Top-level fields
| Field | Type | Description |
|---|---|---|
version | string | Documented value: axhub/v1. Unknown versions may be accepted for forward compatibility, but known fields are still checked. |
name | string | Display-only label. Not enforced against stored app name. |
root | string | The folder to treat as the app's top level when the app lives in a subfolder (monorepo). |
metadata | object | App name, description, and icon kept in the repo so they travel with the code. |
runtime | object | Port, health probe path, health check wait time (healthcheck), and the compose entry service (entry_service). |
build | object | Build/deploy method and command hints. |
database | object | App-owned DB request — engine: postgres, plus extensions for Postgres extensions and roles for extra DB accounts when you need them. |
storage | object | App-owned file storage request — enabled: true. |
env | object | Required/optional env names and scopes. Values never live here. |
ci | object | Pre-build CI commands and timeout. |
runtime
| Field | Type | Limit |
|---|---|---|
port | integer | 1–65535. Falls back to Dockerfile EXPOSE or adapter default. Ignored in compose mode — the compose file decides the ports. |
entry_service | string | Compose only. The service to connect to the outside address. Ignored in docker/static mode. See Compose entry service below for the rules. |
health_path | string | Liveness/readiness probe path. Empty falls back to /. |
healthcheck | object | Docker only. Sets the health check wait time — how long the platform waits for the app to come up. Ignored in compose and static mode. See Health check wait time below for the rules. |
replicas | map | compose only. Service name → pod count. Values are 1 or greater; services you omit follow the platform default. |
cache | object | Docker only. A cache volume that survives restarts and redeploys: size (1Gi to 50Gi) and paths (1 to 8 absolute paths). Compose apps use named volumes instead. See Cache volume below. |
ephemeral_storage | string | The container's ephemeral storage limit. Mi/Gi units like 2Gi or 1536Mi, 1Gi to 10Gi. Empty means the platform default of 1Gi. In compose mode every service gets the same value; static mode ignores it. See Ephemeral storage below. |
Replicas live here rather than in the compose file's deploy.replicas on purpose. The compose file stays pure so docker compose up works locally, and "how to deploy" belongs to the manifest.
runtime:
replicas:
web: 2
beat: 1 # one scheduler — two would fire every job twice
redis: 1Pin schedulers and shared stores to 1. Without per-service values they all scale together, and a cron service at 2 replicas runs every job twice.
Cache volume (cache)
A container's filesystem is gone when the pod dies. Apps that download models or packages at startup fetch the same files again on every restart and redeploy. Put those paths on a cache volume and the files stay.
runtime:
cache:
size: 5Gi # 1Gi to 50Gi
paths:
- /root/.cache/huggingface
- /root/.cache/pipThe platform creates one disk per environment (production and staging each get their own) and mounts the listed paths from it. The paths are writable even when the image runs as a non-root user (USER app and the like). Paths must not overlap (/a and /a/b together are rejected). Write size as a whole number of Mi or Gi, such as 2Gi or 1536Mi. The disk is created when cache first takes effect in that environment, and its size is fixed then: changing size later keeps the existing disk. That is usually the first deploy that declares cache there. Staging is seeded with the version currently in production the moment you turn it on, so if that version uses cache, the staging disk is created right then with that version's size. Changing size in a later staging deploy keeps that disk.
Single-pod apps only. One disk attaches to one pod, so a deploy with 2 or more replicas is rejected. Suspending the app deletes the disk (when staging is turned off, the staging disk is deleted). A new disk is created, with its size set again, when cache next takes effect in that environment (the next deploy or resume, or for staging, turning it back on) — use it only for files you can download again. Compose apps get the same thing from named volumes in the compose file.
If you cannot change the repository, the same values can live in app settings: send cache, ephemeral_storage, port, health_path and healthcheck to PUT /api/v1/apps/{appID}/runtime-overrides and they apply from the next deploy or resume. A GET on the same URL returns what the last production deploy actually used and which of those came from app settings (override_fields). App settings only fill what the repository leaves unset — a declaration in axhub.yaml wins, and for the port a numeric EXPOSE in the Dockerfile wins too.
Ephemeral storage (ephemeral_storage)
How much a container may write to its own filesystem (/tmp, the working directory, ...). The default is 1Gi; exceeding it gets the pod evicted and recreated. Apps that download models, packages or data of several GB at startup hit this limit and loop: download, evicted, download again. Raise it only for those apps.
runtime:
ephemeral_storage: 4Gi # 1Gi to 10Gi, Mi/Gi unitsEphemeral storage is gone when the pod dies. Files that must survive a restart (model caches, ...) belong in the image or in storage (the app's file storage).
Health check wait time (healthcheck)
When you deploy, AxHub waits for the new container to answer its first health check. That wait is the health check wait time. If the app does not answer in time, the container is restarted. If it still doesn't come up, the deployment fails — with healthcheck.startup_budget_exceeded when the restart is confirmed by pod events, otherwise with healthcheck.container_crash. If it answers after the restart, the deploy can still succeed with only a warning (startup_budget_restart).
health check wait time = start_period + interval × retries (60s to 5min)Leave it out and the wait time is 60 seconds. If migrations, seeding, or warm-up keep your app from coming up within 60 seconds, declare it like this. The meaning matches Dockerfile HEALTHCHECK and compose healthcheck.
runtime:
port: 3000
health_path: /health # path the probe hits
healthcheck:
start_period: 90s
interval: 10s
timeout: 5s
retries: 6 # → wait time 90 + 10×6 = 150s| Field | Type | Default | Description |
|---|---|---|---|
start_period | duration | 0s | Failures during this window are not counted |
interval | duration | 3s | Time between checks |
timeout | duration | 5s | Response limit for one check. Values under 5s are raised to 5s — during boot the CPU is scarce and the check itself is slow |
retries | integer | 20 | Consecutive failures allowed |
Rules
- Values must be durations with a unit (
90s,2m). A bare90or a negative value is rejected before the deploy starts. - A result under 60s is raised to 60s, and one over 5 minutes is capped at 5 minutes. Exceeding the cap is not an error.
- Only the fields you write change; the rest keep their defaults.
start_period: 60salone gives 60 + 3×20 = 120s. - Docker only. For compose apps the compose file's
healthcheckis the source of truth and this block is ignored (notestmeans a port check with a 60s wait time;test: NONEordisable: truemeans no check at all). Static apps ignore it too. - The Dockerfile
HEALTHCHECKinstruction is not read — put the same values here. - Readiness and liveness probes are unaffected. Both use only
health_path.
Add the time any migration or warm-up needs. Staging runs on the same resources as production, so the app takes about as long to come up in either environment — you can base the wait time on what you measure on staging.
- Staging, production, promote, and resume all use the same wait time. The
health_pathandhealthcheckin effect at deploy time are stored with the deployment record, so promoting reuses the same values without re-reading the code. - What to do for each failure code (wait time exceeded, crash, and so on) is in Health check failure codes.
build
| Field | Values | Description |
|---|---|---|
strategy | auto | pinned | Default auto. pinned + start can generate a pinned Dockerfile. |
framework | node | python | go | ruby | java | kotlin | rust | unknown etc. | CI/image selection hint. |
install | string | Install command for framework presets. |
build | string | Build command. |
start | string | Runtime start command. |
dockerfile | path | Dockerfile path override. |
deploy_method | docker | compose | static | Docker single-image, Compose multi-service, or a static site. |
compose_file | path | Compose file for compose mode. Default docker-compose.yml. |
static_output_dir | path | Build output directory deployed in static mode, relative to the app root. Default dist. . deploys the app root itself (an HTML site with no build step). |
When the plugin's import brings in a static app, the build that runs on your machine doesn't use install or build. It installs dependencies and runs the build script in package.json with the package manager picked from the lockfile (pnpm-lock.yaml→pnpm · bun.lock or bun.lockb→bun · yarn.lock→yarn · otherwise npm).
When the workspace uses the Axhub repository (self-hosted Git), a static app also pushes its source to the app repository, and the deploy started by that push builds on the server. That build uses install and build; when they are empty it installs dependencies from the lockfile (package-lock.json→npm ci · pnpm-lock.yaml→pnpm · yarn.lock→yarn · otherwise npm install when only package.json exists) and runs npm run build when package.json exists. It then deploys the static_output_dir folder. With ., the whole app root is deployed except .git and node_modules.
deploy_method is immutable on the app DB row, and a manifest value that disagrees with it fails the deploy — changing it usually means recreating the app. The same holds without an axhub.yaml: a repo with only a compose file resolves to compose and auto-detection resolves to docker, and if that differs from the app's value the deploy fails the same way.
Compose entry service
In a multi-service compose app, only one service connects to the outside address ({app}.{tenant}.…). If you don't declare one, the first service that publishes a host port in the compose file becomes the entry point.
To stop depending on service order, declare it in axhub.yaml (recommended):
runtime:
entry_service: backend # compose only — this service gets the outside address- With a declaration, a service without a published host port can be the entry point too — whether it uses
ports: [{target: 8080}]orexpose: ["8080"], that service is opened, using the first declared port. - If the declared name is not in the compose file, the deploy fails (
build.entry_service_not_found— the failure message includes the actual service list). - If the declared service has no ports at all, it also fails (
build.entry_service_has_no_port). - With no declaration, the existing order-based rule applies unchanged.
- The entry service is also the service that receives webhooks. They are delivered to the same service and the same port as the outside address.
What each port style actually does
A service that isn't an "entry candidate" still keeps its internal ports — the two are separate axes. Services reach each other by service name (http://backend:8080).
| Style | Entry candidate | Internal port |
|---|---|---|
ports: ["3000:3000"] | ✅ 3000 | 3000 |
ports: [{target: 7000, published: "7000"}] | ✅ 7000 | 7000 |
ports: ["4000"] (short syntax, no host port) | ❌ | 4000 |
ports: [{target: 6000}] (no published) | ❌ | 6000 |
expose: ["5000"] | ❌ | 5000 |
| No ports declared | ❌ | ❌ none |
A service with no ports declared cannot be reached by name from its siblings. Local docker compose connects services without declarations, so this tends to be a works-only-locally trap — the deploy result carries a warning when it happens. If the service isn't a server (e.g. a queue worker), ignore it.
expose: is absorbed as internal-only ports — internal traffic works, but it never becomes the entry point. Ranges (3000-3005) and udp are unsupported and ignored with a warning.
To move the entry point, move the published host port (or change the entry_service declaration) and redeploy — it switches in one deploy. Address, certificate, and domain settings are per-app and stay put.
Warnings on the deploy result
Warnings never block a deploy — if the setup is intentional, ignore them.
| Warning | Meaning | If intentional |
|---|---|---|
| Multiple services publish ports | Only one connects to the outside address | Switch the others to target: / expose: to make intent explicit |
| Service can't be reached by name | It declares no ports | Fine for workers/batch jobs |
| This app won't open at an address | No service publishes a host port | Normal for batch/worker-only apps |
| Can't read an expose entry | Ranges/udp are unsupported | Split into single port numbers |
| Declared service is connected | entry_service won, and another service publishes a port | Ignore if intended |
metadata
Keep the app's name, description, and icon in the repository and they travel with the code — no filling them in from the console.
metadata:
name: Company Wiki
description: One place to find the team's documents
icon: assets/icon.png
icon_dark: assets/icon-dark.png| Field | What it is |
|---|---|
name | The app's real name. A different field from the top-level name, which is a display-only label |
description | The description shown in the store and lists |
icon, icon_dark | Image paths inside the repo. Relative to root when you've declared one |
- All optional, and applied when the build actually reaches production — either a direct production deploy or the promote that lands a staging build
- Leaving a field blank means "don't touch it." You can't clear an existing value by setting it empty
database
Request an app-owned Postgres database from the manifest. Deploying with the declaration creates the database and injects DATABASE_URL (queries) and DIRECT_DATABASE_URL (migrations) into the runtime.
database:
engine: postgres
extensions: [pgvector, pg_trgm] # optional
roles: [runtime, auth] # optional
owner_bypass_rls: true # optional| Field | Values | Description |
|---|---|---|
engine | postgres | The only supported engine today. This declaration is the only way to turn on an app-owned database. |
extensions | list of strings | Postgres extensions to install into the app DB at deploy time. Must accompany engine, and only names from the list below are accepted. |
roles | list of strings | Extra login accounts next to the existing one (up to 3). Each gets DATABASE_URL_<NAME> · DIRECT_DATABASE_URL_<NAME> injected. A name starts with a lowercase letter and is up to 16 lowercase letters and digits; ro and staging aren't allowed. Must accompany engine. |
owner_bypass_rls | true · false | With true the existing account (table owner) skips RLS (BYPASSRLS), so migrations and backups keep working when a table uses FORCE ROW LEVEL SECURITY. Defaults to false. |
Thirteen extensions can be declared — pgvector (writing vector means the same) · pg_trgm · citext · unaccent · fuzzystrmatch · uuid-ossp · pgcrypto · hstore · ltree · intarray · tablefunc · btree_gin · btree_gist. Names are case-insensitive and duplicates collapse to one. The version installed is the default the DB instance provides.
- Production and staging get separate databases — each is created when you deploy to that environment. If your app uses staging, the production database is created on promote, following the declaration in the staging version you reviewed. Extensions are installed into each environment's DB separately.
- Extensions are installed before the app comes up on the new version. Ones already installed are left as they are; if installation fails, the deploy fails with
prepare.raw_db_extension_failed— you never end up with "deployed, but the extension is missing". - Removing the declaration keeps the database and injection. Turning it off is a CLI-only action (
apps raw-db disable). Extensions aren't removed either when you drop them from the declaration — tables and data using them stay intact. - A static-hosting app (
deploy_method: static) declaring this fails the deploy withprepare.database_unsupported_for_static. - A name outside the list under
extensions, orextensionswithoutengine, is rejected by manifest validation before the deploy starts. The reason shows the offending entry and the accepted values. - Accounts from
rolescan connect but have no table access — your migrations decide withGRANT. They are regular accounts subject to RLS, with up to 5 connections each. Removing one from the declaration only blocks its login; its grants stay. If creating an account fails, the deploy fails withprepare.raw_db_role_failed. - Removing
owner_bypass_rlstakes BYPASSRLS back on the next deploy. Extra accounts never get this right. - Usage walkthrough: the Connect a database; for separate accounts see When you need separate DB accounts.
storage
Request app-owned S3-compatible file storage from the manifest. Deploying with the declaration creates the app's bucket and injects STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_ACCESS_KEY, and STORAGE_SECRET_KEY into the runtime.
storage:
enabled: true| Field | Values | Description |
|---|---|---|
enabled | boolean | true provisions on the next deploy. |
cdn | boolean | true also serves the bucket's files through a signed-URL CDN and injects STORAGE_CDN_BASE_URL · STORAGE_CDN_SIGN_KEY. Must be declared together with enabled: true. |
Any key other than these two is rejected before the deploy runs.
- There is one bucket per app, shared by production and staging.
- Removing the declaration keeps the bucket, files, and injection. Everything is removed only on permanent app deletion.
- A static-hosting app declaring this fails the deploy with
prepare.storage_unsupported_for_static. - Usage, console API, and limits: the Connect storage.
env
The manifest declares env names and scopes only. Store values via axhub env set/update.
env:
required:
- name: OPENAI_API_KEY
scope: runtime
- name: NEXT_PUBLIC_API_URL
scope: build
optional:
- name: SENTRY_DSN
scope: both| Scope | Injection |
|---|---|
build | build args only |
runtime | pod env only |
both | required in both channels |
Omitting scope defaults it to runtime. Names cannot be empty or contain newline/= characters.
Don't list names AxHub injects (DATABASE_URL · DIRECT_DATABASE_URL · STORAGE_* · PORT, etc.) under required. AxHub supplies them to the running app, so there is nothing to save — but they are not available during the build; if the build needs one, save it yourself with scope: build.
ci
| Field | Limit |
|---|---|
commands | max 10 |
timeout | 1–600 seconds (validation range) |
CI commands run before the image build. Failure stops the deploy.
The timeout value is currently validated but not consumed — the pipeline's CI stage budget is fixed at 10 minutes.