Make an app ready for AXHub
The conditions an app must meet to actually start on AXHub, and the deploy failure each one causes when broken. Check this before uploading an app you built elsewhere.
An app you put on AXHub runs on the AXHub server, not on your computer. So an app that works fine locally can fail to deploy — or deploy but not work — if it breaks the conditions below.
Each rule lists what to look for · why it breaks · how to fix it · the failure code you see if the deploy fails.
When an AI does this for you — an AI such as Claude checks the app against this page before deploying, and follows each rule's Handling line.
- Fix directly — fixes it without asking and reports "here is what I adjusted" after the deploy. What the app does stays the same.
- Ask — changes what the app does, needs a value only the user knows, or is hard to undo. Asks once and remembers the answer for later deploys.
- Mention — a constraint that cannot or need not be fixed. Says it once and moves on.
Report problems not listed here too when you are sure of them. Signs found only in tests, example code, or a README are not problems.
1. What an app needs to start
These apply to apps that run as a Docker container (most server apps). For static sites, see section 6.
After a deploy, AXHub sends health check requests to the app. If no healthy answer arrives within 60 seconds, it restarts the app and the deploy eventually fails. The failure code is usually healthcheck.startup_budget_exceeded ("could not answer the health check within N seconds … try raising start_period"), or healthcheck.container_crash when that cannot be confirmed. The message suggests waiting longer, but the real cause is often one of 1-1 to 1-6. Check those before raising the wait.
1-1. Listen on the PORT environment variable
- Look for — the server opening a fixed number such as
3000,5000,8000(app.listen(3000),uvicorn ... --port 8000). - Why it breaks — AXHub picks the port the app must open, passes it as
PORT, and checks the app only on that port. The order isruntime.portinaxhub.yaml→ the first numericEXPOSEin the Dockerfile → the port in the console app settings → (for a generated Dockerfile)--portor${PORT:-N}in the start command →8080. An app that ignoresPORTand opens another port keeps failing the check. - Fix — read the port from
process.env.PORT(Node) oros.environ["PORT"](Python). If the port must stay fixed, write that number asruntime.portinaxhub.yaml. - Handling — Fix directly
- Failure codes —
healthcheck.startup_budget_exceeded,healthcheck.container_crash
1-2. Listen on all addresses (0.0.0.0)
- Look for — the server opened only on
localhostor127.0.0.1(app.run(host="127.0.0.1"),next start -H localhost). - Why it breaks — health checks come from outside the server (the pod address) and never reach a server bound to
127.0.0.1. - Fix — listen on
0.0.0.0. Next.js:HOSTNAME=0.0.0.0. Flask and FastAPI:host="0.0.0.0". - Handling — Fix directly
- Failure codes —
healthcheck.startup_budget_exceeded,healthcheck.container_crash
1-3. The health path answers quickly, without login
- Look for — the health path (default
/) returning 401 · 403 · 404 · 500, or calling a DB or external API before answering. - Why it breaks — the check is an HTTP GET with no login and no
X-AxHub-*headers. A 200–399 answer must arrive within 5 seconds while starting and 3 seconds once running. A single 302 to a login page passes (if that page returns 200). Repeated redirects, 401, or 403 fail. Going over 3 seconds once running can restart the app each time. - Fix — if
/cannot meet this, add a light path that returns 200 right away without login (for example/healthz) and setruntime.health_path: /healthzinaxhub.yaml. Do not call the DB on this path. - Handling — Fix directly
- Failure codes —
healthcheck.startup_budget_exceeded,healthcheck.container_crash
1-4. Host checks do not block the health check
- Look for — settings that check the request's host name, such as Django
ALLOWED_HOSTS. - Why it breaks — the health check's Host is the pod IP, not the app address. If it is not allowed, the app answers 400 and the check fails.
- Fix — exclude the health path from the host check or widen the allowed list (Django:
ALLOWED_HOSTS = ["*"], or a health check middleware placed first). - Handling — Fix directly
- Failure codes —
healthcheck.startup_budget_exceeded,healthcheck.container_crash
1-5. Start within 60 seconds
-
Look for — slow work at startup (bulk data loading, model downloads, long migrations), or heavy apps such as Next.js SSR.
-
Why it breaks — the deploy fails if the app does not answer the health check within 60 seconds. A new app gets 0.5 CPU core by default, so heavy apps can start slowly. The Dockerfile
HEALTHCHECKis not read. -
Fix — move slow work out of startup. If it is still slow, raise the wait in
axhub.yaml(up to 5 minutes, units such as90sor2mrequired) or raise the app size.runtime: healthcheck: start_period: 120s -
Handling — Fix directly (Ask if it changes what the app does, such as a startup step that wipes and reloads data)
-
Failure codes —
healthcheck.startup_budget_exceeded
1-6. One port, plain HTTP
- Look for — an app that only serves HTTPS with its own certificate, or an admin screen on a second port.
- Why it breaks — AXHub terminates HTTPS in front of the app; the app receives plain HTTP. An HTTPS-only app fails the health check. Only one port is exposed, so a screen on a second port deploys but cannot be reached.
- Fix — serve HTTP and merge screens into one port. For several services, use compose.
- Handling — Fix directly
- Failure codes —
healthcheck.startup_budget_exceeded(HTTPS only); none (second port)
2. Build
2-1. There must be a way to build
- Look for — a
Dockerfileat the repository root. If none, whether the app is Node, Python, Go, Ruby, or Java. - Why it breaks — AXHub decides how to build in this order:
axhub.yaml→ rootDockerfile→ compose file → auto-detection. The generated Dockerfile usesnode:22-alpinefor Node andpython:3.12-slimfor Python. For other languages (Rust, PHP, .NET, …) auto-detection fails, or succeeds and then builds on an image without the toolchain. Native modules (better-sqlite3, …) can fail without compile tools (python3 · make · g++). - Fix — add a Dockerfile. If native modules are needed, install the compile tools in the Dockerfile or use a pure JS alternative.
- Handling — Fix directly
- Failure codes —
resolve.spec_invalid(detail:dockerfile not found at repo root: railpack auto-detection failed),build.command_failed,build.native_module_toolchain_missing
2-2. The build finishes on your computer
- Look for — whether the build command (
npm run build, …) finishes without errors. The dev server runs despite type errors; the build stops. Whether every package the code uses is listed inpackage.jsonorrequirements.txt. - Why it breaks — AXHub runs the build command as is. Type errors, dependency conflicts (
ERESOLVE), and missing commands (exit 127) fail on the server too. A package missing from the dependency list builds fine and crashes at startup. A build cannot take longer than 20 minutes. - Fix — run the build once before deploying and fix the errors. Add missing packages to the dependency list. For conflicts, commit the lockfile and use the same package manager.
- Handling — Fix directly
- Failure codes —
build.command_failed,build.dependency_install_failed,build.timeout,healthcheck.container_crash(missing package)
2-3. Runtime variables are not available during the build
- Look for — reading values like
DATABASE_URLduring the build (for exampleprisma.config.tsrequiringenv("DATABASE_URL")at build time), or a hand-written Dockerfile using a build value withoutARG. - Why it breaks — runtime values are not passed to the build. Build values need Stage
BuildorBoth, and a hand-written Dockerfile must declareARG NAMEto read them. - Fix — register build-time values with Stage Build/Both and add
ARGto the Dockerfile. If the build really needs a DB address (Prisma, …), register a well-formed placeholder with Stage Build. At runtime AXHub replaces it with the real value. - Handling — Fix directly
- Failure codes —
build.env_referenced_at_build_time,build.command_failed
2-4. Files the app uses are in the repository
- Look for — data files the app reads that
.gitignoreexcludes; the last change not committed or pushed; files over 100 MB or a committed bulk such asnode_modules. - Why it breaks — the server takes the code from the repository, not from your computer. Over 100 MB for one file, 200 MB in total, or 50,000 files, the source cannot be fetched.
- Fix — commit and push the files the app needs. If the commit is not found right after a push, deploy again shortly. Keep large data in file storage (
storage) or the DB. - Handling — commit and push: Fix directly. Moving large data: Ask.
- Failure codes —
resolve.commit_not_found,build.context_file_missing,prepare.source_package_failed,healthcheck.container_crash(crashes reading the file at startup)
2-5. The deploy method is fixed when the app is created
There are three deploy methods: docker (one container), compose (several services), static (static site). The method is set when the app is created and cannot change later. If the method judged from the repository differs from the app's method, the deploy fails.
- Look for — a
docker-compose.ymlat the root that only starts a local DB, with no Dockerfile. Whether the repository shape matches the method of an app that already exists. - Why it breaks — without a Dockerfile, the compose file is treated as the whole app and judged as compose. For an app created as docker the methods mismatch and the deploy fails, and only the local DB would go up.
- Fix — rename a local-only compose file, for example to
docker-compose.dev.yml, or put a Dockerfile at the root (with a Dockerfile, compose files are not read). If a different method is really needed, the app must be created again. - Handling — renaming or adding a Dockerfile: Fix directly. Creating a new app: Ask.
- Failure codes —
resolve.spec_invalid(detail:deploy method mismatch)
3. Settings
3-1. axhub.yaml at the root, known fields only
- Look for —
axhub.yamlinside a subfolder, unknown fields (including typos). - Why it breaks — only the file at the repository root is read, and one unknown field rejects the whole file. The size limit is 8 KB.
- Fix — if the app lives in a subfolder of the repository, do not move the file; set
root: subfolderin the rootaxhub.yaml. Use only the fields in the axhub.yaml reference. Writeruntime.healthchecktimes with units such as90s; writeci.timeoutas a number of seconds. - Handling — Fix directly
- Failure codes —
resolve.spec_invalid(detail:axhub.yaml 파싱 실패oraxhub.yaml의 …)
3-2. Needed variables are registered in AXHub
- Look for — environment variables the code reads (
process.env.X,os.environ["X"]) andenv.requiredinaxhub.yaml, compared with what is registered in AXHub. Values that exist only in a.envfile. - Why it breaks —
.envfiles are not uploaded. A missingenv.requiredvalue stops the deploy before it starts; a missing value that is not listed either crashes the app at startup or breaks only that feature, depending on how it is read. - Fix — register the value in AXHub and deploy again. Names use uppercase letters, digits, and
_only, and cannot start withAXHUB_. - Handling — if the value is in the local
.env, register it directly — except values pointing at your own computer (localhost,127.0.0.1, private IPs) and the names in 3-3. If the value cannot be found anywhere: Ask. - Failure codes —
prepare.required_env_missing,healthcheck.container_crash
3-3. Do not set values AXHub supplies for runtime
AXHub sets these names in the running app automatically when the condition holds, and they win over a registered value of the same name. They are not available during the build.
| Name | When it is set |
|---|---|
PORT | Always (Docker apps) |
DATABASE_URL, DIRECT_DATABASE_URL | When the environment has an AXHub DB (created by database.engine: postgres) |
STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY | When file storage exists (created by storage.enabled: true) |
STORAGE_CDN_BASE_URL, STORAGE_CDN_SIGN_KEY | When storage.cdn: true |
AXHUB_RELAY_SIGNING_SECRET | Always |
AXHUB_APP_TOKEN, AXHUB_API_URL | When notifications and mail are available |
- Handling — do not copy these names from
.envfor runtime, and do not list them inenv.required. If the build needs one, register it with Stage Build as in 2-3. Fix directly.
4. Storing data
4-1. The AXHub DB starts empty
- Look for — code that creates tables (
CREATE TABLE IF NOT EXISTS, ORM migrations). Reliance on tables created by hand in a local DB. - Why it breaks — the AXHub DB is created empty, separately for production and staging. Without table-creating code the deploy succeeds and requests fail with 500 from the start.
- Fix — create tables when the app starts (
CREATE TABLE IF NOT EXISTS) or run migrations at startup (they must finish within 60 seconds). Migration tools such as Prisma must connect withDIRECT_DATABASE_URL—DATABASE_URLgoes through a transaction-level pooler where migration locks do not work. Neither address uses SSL (sslmode=disable), so remove settings that force SSL. - Handling — Fix directly
- Failure codes — none (the deploy succeeds and requests return 500)
4-2. What you save in the server folder disappears
-
Look for — SQLite (
*.db,better-sqlite3,sqlite3), code that keeps data in JSON files (lowdb, …), uploads saved to an app folder (./uploads). -
Why it breaks — regular folders of a Docker app reset when you deploy again, when the app restarts, or when it wakes from sleep.
-
Fix — move data to the AXHub DB (PostgreSQL) and files to file storage.
database: engine: postgres # DATABASE_URL is set automatically storage: enabled: true # STORAGE_* are set automatically -
Handling — Ask ("Switch to the AXHub DB, or upload as is?"). Leave caches that need not survive, and SQLite used only in tests.
-
Failure codes — none (the deploy succeeds and the data is lost)
4-3. The DB is PostgreSQL only
- Look for — MySQL or MongoDB, or unsupported extensions.
- Why it breaks — the AXHub DB is PostgreSQL only. Allowed extensions: pgvector, pg_trgm, citext, unaccent, fuzzystrmatch, uuid-ossp, pgcrypto, hstore, ltree, intarray, tablefunc, btree_gin, btree_gist.
- Fix — move to PostgreSQL and list extensions under
database.extensions. - Handling — Ask
- Failure codes —
resolve.spec_invalid(extension not in the list),prepare.raw_db_extension_failed(install failed)
5. Network
5-1. Only common ports go out
-
Look for — sending mail directly on port 25, outbound connections on ports not in the list below (for example 8080, 9200), calls to private IPs (10.x, 172.16–31.x, 192.168.x) or other programs on
localhost. -
Why it breaks — for security, outbound connections are open only to the public internet on the TCP ports below. Blocked connections end in a silent timeout. Connecting at startup keeps the app from starting; connecting during a request breaks only that feature.
Use Ports Web 443, 80 Sending mail 465, 587 (25 is blocked) Reading mail 993, 995 SSH · SFTP 22 External DB 3306 (MySQL), 5432 (PostgreSQL), 6379 (Redis), 27017 (MongoDB) These ports apply when the app is deployed again. An app last deployed before 2026-10-16 has only 443 open, so deploy it once more.
-
Fix — send mail on 465 or 587 (for example nodemailer
port: 587). Prefer the AXHub DB for data; if you must use an external DB, check that the service accepts connections from public addresses. Company networks and your own computer cannot be reached (section 8). -
Handling — Ask (it changes what the app connects to). If it cannot change: Mention.
-
Failure codes —
healthcheck.startup_budget_exceeded,healthcheck.container_crash(if it connects at startup); none (if it connects during a request)
5-2. Request size and response time
- Look for — a single request receiving a file over 50 MB, or work inside a request taking more than 2 minutes (long AI batch jobs, large reports).
- Why it breaks — request bodies are limited to 50 MB, and the first response byte must arrive within 120 seconds. Beyond that the request ends with 413 or 504.
- Fix — upload large files straight to file storage (pre-signed address); split long work into "start the job → poll progress", or stream the response.
- Handling — Ask
- Failure codes — none (runtime 413 · 504)
5-3. A private app cannot receive outside webhooks directly
-
Look for — routes that outside services (payments, GitHub, messengers) call (
/api/webhook,/hooks/…, Stripe or Slack signature checks). -
Why it breaks — every request to a private app address goes through the AXHub sign-in. A request from an outside server is not signed in, so it is sent to the sign-in page and never reaches the app. The deploy succeeds; only the webhooks never arrive.
-
Fix — create a relay endpoint and give its address to the outside service. The app stays private, and only verified requests are forwarded to its receiving path. Check forwarded requests with
AXHUB_RELAY_SIGNING_SECRET(Receive webhooks).axhub relay create --app <app> --name <endpoint-name> --verify-mode key --target-path /api/webhook -
Handling — Ask (a new address must be registered with the outside service)
-
Failure codes — none (the deploy succeeds; webhooks do not arrive)
5-4. Call AI through AXRouter
- Look for — local AI server addresses such as Ollama or LM Studio (
localhost:11434), local embeddings or speech recognition (whisper.cpp), and whether the company already has its own AI key. - Why it breaks — the server has no local AI (section 8). If the company has AXRouter turned on, the app can call the company AI gateway (OpenAI-compatible) without a personal AI key.
- Fix — first check whether the company uses AXRouter:
data.availableinaxhub axrouter status --tenant <company> --json.-
true— issue a key, register it as a secret environment variable, and call it the OpenAI-compatible way. Chat (/chat/completions), embeddings (/embeddings), and transcription (/audio/transcriptions) share the same address.axhub axrouter keys issue --name "<app> server" # the key is shown only once axhub env set LLM_API_KEY --app <app> --secret --stage runtime --from-stdin axhub env set LLM_BASE_URL https://axrouter.ai/v1 --app <app> --plain --stage runtimeList the model names with
GET $LLM_BASE_URL/models. Usage counts against the AI budget of the person who issued the key. -
falseor the check fails — don't suggest AXRouter (the company has turned it off). Put the user's own key for their AI service in a secret environment variable.axhub env set OPENAI_API_KEY --app <app> --secret --stage runtime --from-stdin # or ANTHROPIC_API_KEY, etc.
-
- Handling — Ask (which AI to switch to, and whose key to use)
- Failure codes — none (only that feature fails)
6. Static sites
-
Look for — a site that ships only HTML and JS without a server, and whether the app was created as a static site.
-
Why it breaks — static hosting is used only when the app was created as static. The build output folder (default
dist) needs anindex.html, with at most 2,000 files, 50 MB per file, 500 MB in total. The DB and file storage are not available. Writingdeploy_method: staticinto an app created as docker mismatches the method and fails (2-5). -
Fix — for a static app, write the following. An app created as docker can stay docker; sites such as Vite are auto-detected and served.
build: deploy_method: static static_output_dir: distA site with
index.htmlat the root and no build step usesstatic_output_dir: .. When the workspace uses the Axhub repository, deploy by pushing the source with this setting to the app repository — the repository page then shows the code and each commit's deploy result. -
Handling — aligning a static app's settings: Fix directly. Changing the method: Ask.
-
Failure codes —
resolve.spec_invalid(detail:deploy method mismatch),apply.static_release_failed,prepare.database_unsupported_for_static,prepare.storage_unsupported_for_static
7. Resources and sleep
-
Size — a new app gets 0.5 CPU core and 1 GB of memory. Beyond the memory it is restarted (
healthcheck.container_crash). Raise the app size for heavy work such as a browser (PDF generation), OCR, or large files. It applies to the running app right away (up to 16 GB, or below the company limit if one is set).axhub apps update <app> --resource-tier M # XS 0.5 core·1 GB · S 1·2 GB · M 2·4 GB · L 3·8 GB · XL 4·16 GB -
Temporary disk — 1 GB by default. Downloading large files at startup can fill it. Raise it with
runtime.ephemeral_storage: 4Gi, up to 10 GB. -
Scheduled jobs — AXHub has no separate scheduler (cron) feature. Run it inside the app — start a scheduler such as
node-cron(Node) or APScheduler (Python) when the app starts. With two or more replicas the job runs twice. A compose app can have one scheduler service kept at one replica (replicas). It is safest when the app catches up on runs it missed while restarting. -
Sleep — a production app may go to sleep after 72 hours without visitors. While asleep, scheduled jobs (cron) and background work inside the app stop too. The next visit wakes it.
-
Handling — Mention
8. Six things AXHub cannot do
These cannot work on the AXHub server by design. If the code shows signs of them, tell the user and ask what to use instead.
| If the app does this | Signs in the code | Do this instead |
|---|---|---|
| Uses files or programs on your computer | C:\, /Users/, desktop paths, calls to localhost:port other than the app itself | Put the file in the repository or let users upload it |
| Connects to internal company systems | Private IPs, .local · .internal · .corp addresses, intranet addresses | Put sample data in the app |
| Runs an AI model inside the app | Ollama, llama.cpp, vLLM, transformers model downloads, diffusers, GPU code | Call an AI API such as AXRouter (5-4) |
| Is not a website | Electron · Tkinter · PyQt, React Native · Expo · Flutter, Chrome extension manifest.json, Excel macros | Build the same feature as a web page |
| Opens Excel or Hangul to do work | win32com, pywin32, xlwings, comtypes, Hangul automation, AppleScript | Libraries that only read and write files (openpyxl, …) |
| Saves entered data in server files | See 4-2 | AXHub DB · file storage |
- Handling — Ask the first time. If the user says "upload as is", remember it and do not ask again. Ignore signs found only in tests, example code, or a README.
9. Find the rule from a failure code
When a deploy fails, find the rule by its failure code.
| Failure code | Rules to check first |
|---|---|
resolve.commit_not_found | 2-4 (was the commit pushed? if just pushed, retry shortly) |
resolve.spec_invalid | 2-1, 2-5, 3-1, 4-3, 6 (read the detail text too) |
prepare.required_env_missing | 3-2, 3-3 |
prepare.source_package_failed | 2-4 |
prepare.database_unsupported_for_static, prepare.storage_unsupported_for_static | 6 |
prepare.raw_db_extension_failed | 4-3 |
build.command_failed, build.dependency_install_failed, build.timeout | 2-2, 2-1 |
build.native_module_toolchain_missing | 2-1 |
build.env_referenced_at_build_time | 2-3 |
build.context_file_missing, build.dockerfile_parse_error | 2-4 (Dockerfile COPY paths · .dockerignore), Dockerfile syntax |
ci.tests_failed | ci.commands in axhub.yaml failed — is the install command first? |
healthcheck.startup_budget_exceeded | 1-1, 1-2, 1-3, 1-4, 1-6, 5-1 first, then 1-5 |
healthcheck.container_crash | 2-2 (missing package), 2-4 (missing file), 3-2, 5-1, 7 (memory), 1-3 |
healthcheck.rollout_timeout | The app started but was not ready for 5 minutes — 1-3 (3 seconds once running) |
healthcheck.capacity_wait_timeout | Not an app problem. Deploy again shortly |
healthcheck.image_pull_failed | The compose image: name and access |
apply.static_release_failed | 6 |