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: DATABASE_URL
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.
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, and the compose entry service (entry_service). |
build | object | Build/deploy method and command hints. |
database | object | App-owned DB request — engine: postgres. |
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 /. |
replicas | map | compose only. Service name → pod count. Values are 1 or greater; services you omit follow the platform default. |
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.
build
| Field | Values | Description |
|---|---|---|
strategy | `auto | pinned` |
framework | `node | python |
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 |
compose_file | path | Compose file for compose mode. Default docker-compose.yml. |
static_output_dir | path | Build output directory uploaded in static mode. Default dist. |
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.
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.
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| Field | Values | Description |
|---|---|---|
engine | postgres | The only supported engine today. Equivalent to CLI apps raw-db enable. |
- Production and staging get separate databases — each is created when you deploy to that environment.
- Removing the declaration keeps the database and injection. Turning it off is a console/CLI action (
apps raw-db disable). - A static-hosting app (
deploy_method: static) declaring this fails the deploy withprepare.database_unsupported_for_static. - Usage walkthrough: the Connect a database.
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. The only field — unknown keys are 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: DATABASE_URL
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.
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.