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 is runtime.port in axhub.yaml → the first numeric EXPOSE in the Dockerfile → the port in the console app settings → (for a generated Dockerfile) --port or ${PORT:-N} in the start command → 8080. An app that ignores PORT and opens another port keeps failing the check.
  • Fix — read the port from process.env.PORT (Node) or os.environ["PORT"] (Python). If the port must stay fixed, write that number as runtime.port in axhub.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 localhost or 127.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 set runtime.health_path: /healthz in axhub.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 HEALTHCHECK is 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 as 90s or 2m required) 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 Dockerfile at 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 → root Dockerfile → compose file → auto-detection. The generated Dockerfile uses node:22-alpine for Node and python:3.12-slim for 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 in package.json or requirements.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_URL during the build (for example prisma.config.ts requiring env("DATABASE_URL") at build time), or a hand-written Dockerfile using a build value without ARG.
  • Why it breaks — runtime values are not passed to the build. Build values need Stage Build or Both, and a hand-written Dockerfile must declare ARG NAME to read them.
  • Fix — register build-time values with Stage Build/Both and add ARG to 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 .gitignore excludes; the last change not committed or pushed; files over 100 MB or a committed bulk such as node_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.yml at 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.yaml inside 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: subfolder in the root axhub.yaml. Use only the fields in the axhub.yaml reference. Write runtime.healthcheck times with units such as 90s; write ci.timeout as a number of seconds.
  • Handling — Fix directly
  • Failure codes — resolve.spec_invalid (detail: axhub.yaml 파싱 실패 or axhub.yaml의 …)

3-2. Needed variables are registered in AXHub

  • Look for — environment variables the code reads (process.env.X, os.environ["X"]) and env.required in axhub.yaml, compared with what is registered in AXHub. Values that exist only in a .env file.
  • Why it breaks — .env files are not uploaded. A missing env.required value 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 with AXHUB_.
  • 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.

NameWhen it is set
PORTAlways (Docker apps)
DATABASE_URL, DIRECT_DATABASE_URLWhen the environment has an AXHub DB (created by database.engine: postgres)
STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEYWhen file storage exists (created by storage.enabled: true)
STORAGE_CDN_BASE_URL, STORAGE_CDN_SIGN_KEYWhen storage.cdn: true
AXHUB_RELAY_SIGNING_SECRETAlways
AXHUB_APP_TOKEN, AXHUB_API_URLWhen notifications and mail are available
  • Handling — do not copy these names from .env for runtime, and do not list them in env.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 with DIRECT_DATABASE_URL — DATABASE_URL goes 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.

    UsePorts
    Web443, 80
    Sending mail465, 587 (25 is blocked)
    Reading mail993, 995
    SSH · SFTP22
    External DB3306 (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.available in axhub 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 runtime

      List the model names with GET $LLM_BASE_URL/models. Usage counts against the AI budget of the person who issued the key.

    • false or 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 an index.html, with at most 2,000 files, 50 MB per file, 500 MB in total. The DB and file storage are not available. Writing deploy_method: static into 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: dist

    A site with index.html at the root and no build step uses static_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 thisSigns in the codeDo this instead
Uses files or programs on your computerC:\, /Users/, desktop paths, calls to localhost:port other than the app itselfPut the file in the repository or let users upload it
Connects to internal company systemsPrivate IPs, .local · .internal · .corp addresses, intranet addressesPut sample data in the app
Runs an AI model inside the appOllama, llama.cpp, vLLM, transformers model downloads, diffusers, GPU codeCall an AI API such as AXRouter (5-4)
Is not a websiteElectron · Tkinter · PyQt, React Native · Expo · Flutter, Chrome extension manifest.json, Excel macrosBuild the same feature as a web page
Opens Excel or Hangul to do workwin32com, pywin32, xlwings, comtypes, Hangul automation, AppleScriptLibraries that only read and write files (openpyxl, …)
Saves entered data in server filesSee 4-2AXHub 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 codeRules to check first
resolve.commit_not_found2-4 (was the commit pushed? if just pushed, retry shortly)
resolve.spec_invalid2-1, 2-5, 3-1, 4-3, 6 (read the detail text too)
prepare.required_env_missing3-2, 3-3
prepare.source_package_failed2-4
prepare.database_unsupported_for_static, prepare.storage_unsupported_for_static6
prepare.raw_db_extension_failed4-3
build.command_failed, build.dependency_install_failed, build.timeout2-2, 2-1
build.native_module_toolchain_missing2-1
build.env_referenced_at_build_time2-3
build.context_file_missing, build.dockerfile_parse_error2-4 (Dockerfile COPY paths · .dockerignore), Dockerfile syntax
ci.tests_failedci.commands in axhub.yaml failed — is the install command first?
healthcheck.startup_budget_exceeded1-1, 1-2, 1-3, 1-4, 1-6, 5-1 first, then 1-5
healthcheck.container_crash2-2 (missing package), 2-4 (missing file), 3-2, 5-1, 7 (memory), 1-3
healthcheck.rollout_timeoutThe app started but was not ready for 5 minutes — 1-3 (3 seconds once running)
healthcheck.capacity_wait_timeoutNot an app problem. Deploy again shortly
healthcheck.image_pull_failedThe compose image: name and access
apply.static_release_failed6