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

axhub.yaml
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: 300

All 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.

axhub.yaml (repository root)
root: web

That 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.json and friends)
  • Generated Dockerfiles and the build context (what gets copied, which .dockerignore applies)
  • 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 writeResult
web, ./web, web/All treated as web
web/appMore than one level deep is fine
., ./Same as not declaring it (repository root)
/web, ../otherFails before the deploy — only relative paths inside the repo
A folder with no files in itFails 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 root declared, 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 root declaration behave exactly as they did before this existed

Files and precedence

ItemCurrent contract
canonical filenameaxhub.yaml
max size8 KiB
prioritymanifest > DB AppSpecData > resolver default
deploy resolutionmanifest → 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.

axhub.yaml있으면 이 설정대로Dockerfile단일 이미지 빌드Compose 파일docker-compose.yml 등자동 감지package.json 등으로 추론위에서부터 검사해서 처음 맞는 방법 하나로 빌드해요
빌드 방법이 정해지는 순서

Here is exactly what each step looks at.

  1. axhub.yaml — if present, this manifest wins. A build.dockerfile override and strategy: pinned + start pinned execution take effect at this step.
  2. Dockerfile — if present at the root, the app is built as a single image.
  3. Compose file — searched in the order docker-compose.yml · docker-compose.yaml · compose.yaml · compose.yml.
  4. 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

FieldTypeDescription
versionstringDocumented value: axhub/v1. Unknown versions may be accepted for forward compatibility, but known fields are still checked.
namestringDisplay-only label. Not enforced against stored app name.
rootstringThe folder to treat as the app's top level when the app lives in a subfolder (monorepo).
metadataobjectApp name, description, and icon kept in the repo so they travel with the code.
runtimeobjectPort, health probe path, health check wait time (healthcheck), and the compose entry service (entry_service).
buildobjectBuild/deploy method and command hints.
databaseobjectApp-owned DB request — engine: postgres, plus extensions for Postgres extensions and roles for extra DB accounts when you need them.
storageobjectApp-owned file storage request — enabled: true.
envobjectRequired/optional env names and scopes. Values never live here.
ciobjectPre-build CI commands and timeout.

runtime

FieldTypeLimit
portinteger1–65535. Falls back to Dockerfile EXPOSE or adapter default. Ignored in compose mode — the compose file decides the ports.
entry_servicestringCompose only. The service to connect to the outside address. Ignored in docker/static mode. See Compose entry service below for the rules.
health_pathstringLiveness/readiness probe path. Empty falls back to /.
healthcheckobjectDocker 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.
replicasmapcompose only. Service name → pod count. Values are 1 or greater; services you omit follow the platform default.
cacheobjectDocker 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_storagestringThe 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: 1

Pin 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/pip

The 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 units

Ephemeral 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
FieldTypeDefaultDescription
start_periodduration0sFailures during this window are not counted
intervalduration3sTime between checks
timeoutduration5sResponse limit for one check. Values under 5s are raised to 5s — during boot the CPU is scarce and the check itself is slow
retriesinteger20Consecutive failures allowed

Rules

  • Values must be durations with a unit (90s, 2m). A bare 90 or 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: 60s alone gives 60 + 3×20 = 120s.
  • Docker only. For compose apps the compose file's healthcheck is the source of truth and this block is ignored (no test means a port check with a 60s wait time; test: NONE or disable: true means no check at all). Static apps ignore it too.
  • The Dockerfile HEALTHCHECK instruction 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_path and healthcheck in 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

FieldValuesDescription
strategyauto | pinnedDefault auto. pinned + start can generate a pinned Dockerfile.
frameworknode | python | go | ruby | java | kotlin | rust | unknown etc.CI/image selection hint.
installstringInstall command for framework presets.
buildstringBuild command.
startstringRuntime start command.
dockerfilepathDockerfile path override.
deploy_methoddocker | compose | staticDocker single-image, Compose multi-service, or a static site.
compose_filepathCompose file for compose mode. Default docker-compose.yml.
static_output_dirpathBuild 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}] or expose: ["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).

StyleEntry candidateInternal port
ports: ["3000:3000"]✅ 30003000
ports: [{target: 7000, published: "7000"}]✅ 70007000
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.

WarningMeaningIf intentional
Multiple services publish portsOnly one connects to the outside addressSwitch the others to target: / expose: to make intent explicit
Service can't be reached by nameIt declares no portsFine for workers/batch jobs
This app won't open at an addressNo service publishes a host portNormal for batch/worker-only apps
Can't read an expose entryRanges/udp are unsupportedSplit into single port numbers
Declared service is connectedentry_service won, and another service publishes a portIgnore 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
FieldWhat it is
nameThe app's real name. A different field from the top-level name, which is a display-only label
descriptionThe description shown in the store and lists
icon, icon_darkImage 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
FieldValuesDescription
enginepostgresThe only supported engine today. This declaration is the only way to turn on an app-owned database.
extensionslist of stringsPostgres extensions to install into the app DB at deploy time. Must accompany engine, and only names from the list below are accepted.
roleslist of stringsExtra 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_rlstrue · falseWith 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 with prepare.database_unsupported_for_static.
  • A name outside the list under extensions, or extensions without engine, is rejected by manifest validation before the deploy starts. The reason shows the offending entry and the accepted values.
  • Accounts from roles can connect but have no table access — your migrations decide with GRANT. 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 with prepare.raw_db_role_failed.
  • Removing owner_bypass_rls takes 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
FieldValuesDescription
enabledbooleantrue provisions on the next deploy.
cdnbooleantrue 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
ScopeInjection
buildbuild args only
runtimepod env only
bothrequired 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

FieldLimit
commandsmax 10
timeout1–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.