Three ways in, one pipeline: a GitHub repository (when you press Deploy or call the API), an upload (dashboard, CLI, SDK or files sent by an AI assistant), or a commit in Harakumo Repos. A failed deploy never replaces a working one.
In plain words
Puts your website or app on the internet at its own address. Upload your files or point it at your code on GitHub, and it builds them. If a new version fails, the last working one stays online.
For example: A bakery uploads its menu site and it is live at its own address a minute later.
How your code is recognized, in order
- A
worker.js(or_worker.js) at the root becomes an edge program with your production environment variables (see Edge programs below). - A Next.js app that is not a static export becomes a server app.
- An index.html in the root or in dist/, build/, out/, _site/, public/, site/ or docs/ becomes a static site. (From Git, a repository with a build script only serves dist/, build/, out/ or _site/.)
- A package.json build script (Vite, Astro, Create React App …) builds on a build machine; the output becomes a static site. The build machines have Node.js but no Flutter toolchain, so a Flutter web build (
flutter build web) is not started: the deploy answers 422 with codeflutter_web_buildinstead of starting a build that cannot finish. Runflutter build webon your machine and upload thebuild/webfolder, which is a static site. - A package.json start script that runs a server (Express and friends, or an Expo app's own server output) becomes a server app.
- A phone app (Expo, React Native, Flutter, or an Xcode or Android Studio project at the root) with nothing above to deploy is not deployed. That is one whose start script only runs a development server for a phone (
expo start,expo run:ios,react-native start,react-native run-android,flutter runand the like, also when the start script calls one throughnpm run,yarnorpnpm), or that has none. Phone apps ship through the App Store and Google Play. The deploy answers 422 with codephone_app, before anything is built, and says how to deploy the app's companion website from the same files: a site/ or docs/ folder with an index.html, or for Expo a build script that exports the web version. A GitHub deploy can instead fail during the build, with the same message in its log. See Use Harakumo from a phone app.
A server app must not carry a static index.html in the places step 3 looks, or it is served as a static site. Keep its pages in another folder. Server apps (steps 2 and 5) are on Pro and up; static sites, edge programs and functions deploy on every plan.
Static sites
- Served from the edge cache (the workspace's CDN setting), then object storage. They scale with traffic.
- Up to 500 files per site.
- Dotfiles (.env, .git …), environment files named without the dot (env.production, prod.env …) and key files (.pem, .key, id_rsa …) are never served;
/.well-known/is. - Pages answer without
.html:/privacyis served fromprivacy.htmlwhen there is no file or folder by that exact name, and/privacy/with the slash redirects to/privacy. A folder with an index.html comes first: then/privacyredirects to/privacy/, which servesprivacy/index.html. - Single-page apps: put a
_redirectsfile with the one rule/* /index.html 200in the folder the site is served from, and a route that matches no file is answered with index.html and a 200, for the app's own router. That is the only rule read from_redirects: any other rule in it is not applied, and the deploy log says how many were left out. Without it, a missing page is a 404 (your 404.html when there is one). - Neither of those answers under
/.well-known/or for a path with a file extension: app-link and domain checks get the real file or a real 404. - Each file is served with the type its extension names.
apple-app-site-association, which has no extension, is served asapplication/json.
Edge programs (worker.js)
- Your own program on the edge, on every plan: its default export's
fetch(request, env, ctx)answers every request to the project's address, at the edge location nearest each visitor, with nothing to keep awake. It reads the project's production variables andHARAKUMO_PROJECT_IDasenv.NAME. - CPU time: each request may use 50 ms on Hobby, 1 second on Pro, 2 seconds on Team and 10 seconds on Scale, the same as a function on that plan. Waiting on the network or a database does not count, so an API that calls other services rarely comes near it; work done in the program itself (resizing images, making PDFs, heavy cryptography) does. A request past it fails. The deploy log names the ceiling, and a plan change applies on the next deploy.
- Changed on 2026-10-11: a worker.js program deployed before then has no ceiling of its own, and gets the plan's on its next deploy. On Hobby, check that a program doing heavy work per request still answers after redeploying it, or move that work to a server app.
- In front of your code, from its next deploy: plain HTTP is redirected to HTTPS, security headers are added, and Shield's country rules turn a blocked visitor away before the program runs. Shield's address lists, AI-crawler rule and log-only mode do not reach a worker.js program yet.
Server apps
- On Pro and up. On Hobby a deploy that would run as a server app answers 402
plan_limit(withrequired_plan) before anything is built. Server apps that were already live on a free workspace when server apps moved to Pro keep running and can be redeployed. A server app deployed on a paid plan is not one of those: if its workspace moves to Hobby, new deployments of it are refused. - Run with
npm start(ornext start) on Node 22 in one container: about a quarter of a CPU and 1 GB of memory, one copy. - Listen on
process.env.PORT. Production environment variables, andHARAKUMO_PROJECT_ID, are injected at deploy. - Plain HTTP is redirected to HTTPS, and answers carry HSTS, nosniff and a referrer policy. Shield's country rules are checked before the app is woken, so a blocked visitor never reaches it; a change to them reaches the app within about a minute, with no redeploy.
- Deploying a static site or a worker.js program over a server app stops the app's containers and deletes their snapshots; when they cannot be stopped that time, the deploy log says so, the old app gets no traffic from the address, and deleting the project stops it.
- After 15 idle minutes the app sleeps; the next visitor sees a short starting page while it wakes (usually seconds, longer on a cold start).
- WebSockets reach the app. Messages on an open connection count as activity, so the app stays awake while they flow; a connection that has gone quiet does not keep it awake.
- The disk is wiped on every sleep and deploy: keep uploads and data in Storage and Databases.
- If the app crashes three times, the project shows App crashed with the app's own output, and visitors get a plain 503 page.
- At most 40 server apps are awake across the platform today; sleeping apps do not count.
- On Pro, Team and Scale, every hour a server app is awake is charged at $0.05 from the paying workspace's Balance, daily, with no hours included (Team's $20 monthly usage credit pays first); an app asleep costs nothing. While a charge cannot be paid, new deploys wait; apps already running keep running. A Pro subscription from before these plans and Enterprise are not charged by the hour.
From GitHub
- Set the repository once (at project creation or
PATCH /api/projects/:id { repoUrl, repoBranch }). Only github.com repositories are supported. - Deploys run when you press Deploy or call the API. A push to GitHub does not deploy on its own yet.
- Only the production branch deploys. A different branch is refused unless
repoUrlis set in the same call, which makes that branch production. - Private repository: add a
GITHUB_TOKENenvironment variable to the project (a fine-grained token with read access to that repository). It is used only for that project.
From an upload
- Up to 25 MB compressed, 40 MB unpacked, 10 MB per file and 5,000 files.
- Leave out node_modules and build output: the build machine installs and builds. The dashboard leaves node_modules, .next, .git, .vercel, .turbo, .cache and .env files behind for you.
- Put secrets in environment variables, never in an uploaded .env file.
- Harakumo also reads the text of every uploaded file for what looks like a real key (a private key, a live Harakumo or payments key, a cloud access key, a Git hosting or chat app token, an AI service key). The deploy goes ahead, and its log and the upload's answer (
warnings) name the file and line, never the key: revoke such a key and keep the new one under Environment. Deploys from GitHub are not read this way. - A key limited to the project can deploy it; a read-only key can read deployments and logs.
What a folder upload from your machine leaves out
From version 0.3.0 of the CLI and the SDK, uploading a folder from your machine (harakumo deploy <projectId> <dir>, or hk.deployments.uploadDir in Node) sends what git add would. Before anything goes, it says how many files it will send, their total size, and what it left out and why:
- Installed packages, build output, caches and tool folders, wherever they sit: node_modules, .git, .next, .vercel, .turbo, .cache, .svelte-kit, .nuxt, .output, .astro, coverage, __pycache__, a Python virtual environment and the like. The build machine makes its own.
- dist/, build/ and out/ at the top of the folder when package.json has a build script, because the build machine runs it and makes them again.
- Files that hold secrets by their name: .env files however they are spelled, private keys and certificates, cloud service-account keys, saved logins (.npmrc, .netrc, .pgpass and the like) and the folders where tools keep keys and logins, such as .ssh/. Set those values as environment variables instead (
harakumo env set <projectId> KEY=VALUE). - Whatever a .gitignore or a .harakumoignore excludes: in the folder, in any folder inside it, and in the .gitignore files above it up to the top of its Git repository. A .gitignore higher up that excludes the folder itself (a repository that ignores dist/, when dist/ is what you upload) is not applied, since you chose that folder.
- System files such as .DS_Store, and symbolic links, which are never followed.
.harakumoignore
A .harakumoignore file leaves out more, or puts something back, without changing your .gitignore. It uses the .gitignore syntax: one pattern per line, # for a comment, / at the end for folders only, / at the start to match only beside the file, *, ?, [a-z] and **, and ! to bring back what an earlier rule left out. One inside a subfolder applies to that subfolder.
A !dist/ line sends a dist/ folder that the build-script rule would leave out, for a site you build on your own machine.
# Leave these out
drafts/
*.psd
/notes.md
# Send the dist/ folder built on this machine
!dist/A secret written in a file stops the upload
Before sending, the CLI and the SDK read the files that would go and look for secrets written inside them: a private key, a live Harakumo API key, a live payment secret key, a cloud access key, a GitHub, Slack or npm token, or an AI provider's key. If they find one, nothing is uploaded. The file and line are printed, never the secret. Move the value into an environment variable that only server code reads, or list the file in .harakumoignore.
Example values, such as hk_live_… or a key containing "example" or "your", do not count. A private key always does.
See what would go first: --dry-run
harakumo deploy <projectId> <dir> --dry-run lists every file that would be uploaded and its size, then what it left out and why, and sends nothing. It needs no login. It stops where a real upload would stop before sending: a secret written in a file, a folder with nothing left to send, or one over the upload limits.
In code, planUpload(dir) (also hk.deployments.planUpload) returns the same plan without sending it, and uploadDir(projectId, dir, { plan }) sends exactly that plan.
harakumo deploy 12 ./my-app --dry-run # what would go, and what stays here
harakumo deploy 12 ./my-app # the same check, then the uploadRedeploy and roll back
POST /api/deployments/:id/redeploy deploys the same source again as a new deployment: the kept files of an upload (the serving deployment's files are kept) or a GitHub deployment's commit. Redeploying an older GitHub deployment is a rollback. This is also how environment variable changes take effect.
DELETE /api/deployments/:id deletes one deployment with everything it left behind: its files, its kept source and snapshot and, for a server app, its container. It is refused with 409 while it is still running, for the deployment the project serves now, and for one that still answers a custom domain (deployment_serving). If any part cannot be removed, the deployment is kept and the answer says so (502); deleting again retries.
Build minutes
The time each deploy takes, failed ones included, counts toward the build minutes the plan includes each calendar month (UTC): 100 minutes hobby, 1,000 minutes pro, 5,000 minutes team, 10,000 minutes scale, on Hobby across all your free workspaces, and on a paid plan across the workspaces it covers. On Hobby, new deploys are refused once the plan's build minutes allowance is used up (402 usage_limit). On Pro, Team and Scale, use past it is charged daily from the paying workspace's Balance at $0.01 per minute (Team's $20 monthly usage credit pays first), and new deploys pause (402 usage_paused) only while a charge cannot be paid. Billing → Usage shows the month so far.
Status and logs
A deployment moves queued → building → deploying → ready, or failed with the reason in its logs. Server apps also report runtime status (running, crashed or failed) and recent output in runtime_status and runtime_logs.
Each deployment records who started it. triggered_by is the person: whoever was signed in, the owner of a connected AI assistant, or for an API key (the CLI, the SDK, an agent) whoever created the key. triggered_via names the API key when one was used, for example "API key: ci", and is empty otherwise.
const { deployment } = await hk.deployments.uploadDir(projectId, './dist');
let d = deployment;
while (!['ready', 'failed'].includes(d.status)) {
await new Promise(r => setTimeout(r, 3000));
({ deployment: d } = await hk.deployments.get(deployment.id));
}
console.log(d.status, d.preview_url);Reference
await hk.projects.update(project.id, { repoUrl: 'https://github.com/you/repo', repoBranch: 'main' });
const { deployment } = await hk.deployments.create(project.id); // latest commit on the production branch
const { deployment: d } = await hk.deployments.get(deployment.id); // status, logs, preview_url
const { deployment: up } = await hk.deployments.uploadDir(project.id, './my-app'); // Node: a folder
// await hk.deployments.uploadArchive(project.id, tarball); // a gzip tar (Buffer, Uint8Array or Blob)harakumo deploy 12 # the project's GitHub repository
harakumo deploy 12 ./my-app # upload a folder
harakumo deploy 12 ./my-app --dry-run # list what would be uploaded; sends nothing
harakumo deploys 12 # recent deployments with status and URLPOST /api/projects/:id/deployments { "repoUrl"?, "branch"? } # branch must be the production branch
GET /api/deployments/:id # status, logs, preview_url, runtime_status, runtime_logs
POST /api/deployments/:id/redeploy # same files or commit again (rollback for GitHub)
DELETE /api/deployments/:id # one deployment and its files; 409 for the one being served
# Deploy without a repository — a gzip tar (tar -czf app.tgz -C my-app .):
curl -X POST https://harakumo.com/api/projects/:id/deployments/upload \
-H "Authorization: Bearer hk_live_…" -H "content-type: application/gzip" --data-binary @app.tgz
# … or files as JSON (binary files with "base64": true):
curl -X POST https://harakumo.com/api/projects/:id/deployments/upload \
-H "Authorization: Bearer hk_live_…" -H "content-type: application/json" \
-d '{ "files": [{ "path": "index.html", "content": "<h1>hi</h1>" }] }'