Building an app for Haechy
Listen on $PORT (8080), keep anything that must last in Postgres through DATABASE_URL,
read the signed-in person from the X-Haechy-User-* headers instead of building a login, put keys in
app settings, and send email through $HAECHY_API. The file system is read-only except
/tmp, and outbound requests work only to hosts a person has approved.
This page is written for Claude and for developers. If you build by talking to Claude, you can skip it: Claude reads it for you.
The five rules
- Bind to
$PORT. - Store durable data in the app's Postgres database, not on disk.
- Don't build a login. Read the identity headers.
- No secrets in code or in a
.envfile. Use app settings. - Send email with the Haechy email API, and ask for approval before calling any outside host.
Stack detection
A haechy.json in the project root wins over detection. Its keys are stack (node, python or static), build, start and port.
{
"stack": "node",
"build": "npm run build",
"start": "node server.js",
"port": 8080
} Without it, Haechy looks at the files:
- Node (
package.json, imagenode:24-slim). Start is thestartscript, elsemain, else the first ofserver.js,index.js,app.js,server.mjs,index.mjs,main.js. A project with a build script but no start is served as a static site fromdist/,build/orout/. - Python (
requirements.txtorpyproject.toml, imagepython:3.12-slim). Start is theweb:line of aProcfile, else a FastAPI app (run with uvicorn) or Flask app (gunicorn) found in a top-level.pyfile, else a Djangowsgimodule, elseapp.py,main.pyorserver.py. - Static site (
index.html).
If none of these is found, the build fails with "Couldn't tell how to run this app: add a package.json (Node), requirements.txt (Python) or index.html (static site), or a haechy.json."
Builds can reach package registries. The running app cannot reach the internet. One build runs at a time per host.
Port
PORT is 8080, or the port value from haechy.json. Read it from the environment. The deploy check warns when Node code listens on a fixed port other than 8080.
Database
Each app has its own Postgres 17 database and a role that can connect to that database only. The platform sets DATABASE_URL and the PG* variables. The database is created on the first deploy. Run migrations from the app at start-up, or ask Claude to run changes with execute_database_sql (read-only checks use query_database, which returns up to 200 rows and stops after 9 seconds).
Files
The container's file system is read-only apart from /tmp, which is a 64 MB memory-backed folder that is not kept between restarts. Don't write uploads or caches anywhere else.
Who is signed in?
Haechy signs people in before a request reaches the app and adds these headers. Copies sent by the client are always stripped, so trust these and nothing else.
| Header | Value |
|---|---|
X-Haechy-User-Email | The person's email address. |
X-Haechy-User-Name | Display name, percent-encoded if it isn't plain ASCII. |
X-Haechy-User-Id | Stable, per-app id (u_ plus 22 characters): the same email always gets the same id in this app and a different id in any other Haechy app. It can't be reversed to the address. Use it as the user key in your tables. |
X-Haechy-User-Role | owner (the workspace owner), member (admins, listed app members, and workspace members on apps that aren't private) or viewer (people let in by domain or public mode). |
From the browser, GET /_haechy/me on the app's own host reports the session. Signed in returns 200:
{ "signedIn": true, "email": "sam@example.com", "name": "Sam", "id": "…", "role": "member", "expiresAt": "…" } Not signed in returns 401:
{ "signedIn": false, "access": "private" } Send people to /_haechy/signout to sign out. In a non-public app, a browser GET without a session is redirected to sign-in, while a request with Accept: application/json, or any non-GET request, gets 401 {"error": "signin_required", "signin": "<url>"}. In public mode nobody has to sign in, but a visitor who has a session still receives the headers.
Sessions last 12 hours. Sharing and sign-in explains the access modes.
Secrets and settings
Set values with set_env_vars (secret by default). They are stored encrypted, never shown again, and the app restarts with them within a few seconds. Remove one with delete_env_var; list_env_vars shows names.
The deploy check blocks the deploy for a written-in value such as an AWS key, a Stripe live key, an Anthropic or OpenAI-style key, a GitHub, Slack, Twilio, SendGrid or Resend key, a Google API key, a PEM private key, a database URL with a password, or a literal assigned to a name like password, secret, api_key, access_token or client_secret. It also blocks a bundle that contains a .env file (example, sample and template files are allowed).
Calling outside services
The app has no route to the internet. The platform sets HTTP_PROXY, HTTPS_PROXY (and lower-case forms), NO_PROXY and NODE_USE_ENV_PROXY=1, so Node's fetch goes through Haechy's proxy. Most HTTP clients in other languages honour the proxy variables too.
The proxy allows only approved host and port pairs (an exact host or *.domain, default port 443). It resolves DNS itself and refuses private, loopback, link-local, CGNAT and cloud-metadata addresses. Each app can hold 64 concurrent outbound connections.
Call request_outbound_access with the host and a reason of 5 to 500 characters before the app needs it. A person approves; Claude can't. Approving outside services covers the flow. Raw IP addresses, localhost, .local, .internal and .onion names, and haechy.app itself are refused.
A blocked plain-HTTP request gets 403 with the body:
Blocked by Haechy: api.example.com is not an approved connection for this app. Ask Claude to request access. An HTTPS request can't show that body. In Node it surfaces as "Request was cancelled" or Proxy response (403) !== 200. list_outbound_access shows hosts blocked in the last 14 days with a count.
Sending email
curl -X POST "$HAECHY_API/v1/email" \
-H "Authorization: Bearer $HAECHY_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":["sam@example.com"],"subject":"Your order","text":"It shipped."}' Fields: to (1 to 50 addresses), subject (up to 300 characters), html and/or text, and optional replyTo. The request can be 512 KB and the body 200 KB. GET $HAECHY_API/v1/whoami returns {"appId": "…"}.
Mail arrives from "App Name via Haechy" <slug@haechy.app>, and Reply-To defaults to the workspace owner. Each plan has a monthly allowance. On Free, recipients must be workspace members, app members or the owner. Sign-in emails never count. SMTP ports 25, 465, 587 and 2525 can never be approved, so use this API.
Platform variables
| Name | Meaning |
|---|---|
PORT | Port to listen on (8080 unless haechy.json says otherwise). |
DATABASE_URL, PG* | Connection to the app's Postgres database. |
HTTP_PROXY, HTTPS_PROXY, NO_PROXY | Outbound proxy settings (lower-case forms are set too). |
NODE_USE_ENV_PROXY | Set to 1 so Node's fetch uses the proxy. |
HAECHY_API | Base address of the email API. |
HAECHY_APP_TOKEN | Bearer token for the email API. |
HAECHY_APP_URL | The app's own address. |
HAECHY_APP_ID | The app's id. |
HAECHY_REGION | The region the app runs in. |
The owner's own settings follow these, and the platform's values win on a name clash.
Resources and sleeping
The container runs under gVisor as a non-root user (uid 10001) with all capabilities dropped, at most 256 processes, and no published ports. Memory and CPU are capped by plan, and the memory cap is enforced: a process that goes over it is killed. Plan limits lists the numbers. An app that is idle on a plan that sleeps apps answers its next request with a "waking" page (HTTP 503, Retry-After: 3) that refreshes itself while the container starts.
Bundle limits
- Inline deploy: files in the tool call, 2 MB total.
- Upload deploy: a tar.gz up to 25 MB, built from the folder without
node_modules,.git,.env,__pycache__and.venv. The upload URL is single-use and valid for 30 minutes. - On the host: at most 10,000 files and 200 MB unpacked.
Hosts mentioned in browser-side folders (public/, static/, dist/, build/, out/, assets/, www/) and in .html and .md files are not listed by the deploy check, because those requests come from the visitor's browser. More on the check is on the security page.