Building apps
App structure & app.json
An Astrodock app is just a normal folder of code plus one small file, app.json,
that tells the platform what to build and how to run it. This page covers the folder layout
Astrodock auto-detects, the handful of rules your server must follow, and every field in
app.json.
New here?
If you haven't shipped anything yet, start with Deploy your first app — it walks the whole loop end to end. This page is the reference you'll come back to while building your own.
The app layout
You don't configure a router or a build pipeline. You arrange your files in one of a few shapes, and the runner figures out the rest:
| Shape | What's in the folder | How it's served |
|---|---|---|
| Frontend + server | app/ (frontend) and server/ (Express) |
Built frontend at the app URL; /api/* proxied to the server |
| Server-only | A standalone server.js (no app/) |
Only /api/*, proxied to the server. No static frontend. |
| Frontend-only | A standalone package.json with no server |
Just the built static frontend. No /api. |
| Docker | A Dockerfile (with runtime.type: "docker") |
The whole subdomain is proxied to your container |
The most common shape is the first one — a frontend and a server living side by side:
my-app/
├── app.json ← the manifest (always)
├── app/ ← frontend (e.g. Vite/React); builds to app/dist/
│ ├── package.json
│ └── src/…
└── server/ ← Express server
├── package.json
└── server.js ← entry point
The frontend builds to app/dist/, and Astrodock serves that static output at
https://<subdomain>.<base-domain> with an SPA fallback (unknown paths
return index.html so client-side routing works). Anything under /api/*
is proxied to your server instead. For Docker apps there is no app/ + server/
split — your container serves everything; see Dockerfile apps.
The hard rules for a server
If your app has a server, it must follow four rules. They're small, and breaking one is the usual reason a deploy looks healthy but doesn't work:
-
Bind
process.env.ASTRODOCK_PORTThe platform tells your server which port to listen on. Don't hardcode
3000. An unprefixedPORTalias is also provided for convenience, but prefer the explicit one.const PORT = process.env.ASTRODOCK_PORT || process.env.PORT || 3000; app.listen(PORT); -
Put every route under
/apiCaddy only proxies
/api/*to your server; everything else is the static frontend. A route at/loginwill 404 — it has to be/api/login. -
Answer
GET /healthwith a 200After every deploy the platform probes your server. A 200 from
/healthmarks the deploy healthy. (This is the one route that lives outside/api.)app.get('/health', (req, res) => res.json({ status: 'ok' })); -
Read config from injected env — never hardcode
Database URLs, storage credentials, auth secrets, and your own declared variables all arrive as environment variables at deploy time. Read them from
process.env; never paste a secret into your code or commit it.
A 502 after a clean deploy?
Almost always rule 1 — the server isn't binding ASTRODOCK_PORT (or it crashed
on boot). A /api/... route that 404s is almost always rule 2. Check
astrodock logs.
A full app.json, annotated
Here is a complete manifest for a frontend + server app that uses platform login, the bundled database, and one app-specific variable. The notes after each block explain what it does — the file itself is plain JSON with no comments.
{
"schemaVersion": "1",
"slug": "invoice-tool",
"name": "Invoice Tool",
"description": "Generate and store client invoices",
"subdomain": "invoices",
"source": {
"branch": "main",
"repoPath": ""
},
"runtime": {
"type": "node",
"buildCommand": "npm run build"
},
"auth": { "mode": "platform" },
"database": { "mode": "internal" },
"storage": { "mode": "internal" },
"env": [
{ "key": "OPENAI_API_KEY", "secret": true, "required": true,
"description": "Used to draft line items" },
{ "key": "INVOICE_PREFIX", "secret": false, "required": false,
"default": "INV-", "description": "Prefix for invoice numbers" }
]
}
slugis the app's permanent identity — it names the process, the internal database, and the storage prefix. It can't change later.subdomainis where the app lives:invoices.<base-domain>. You can change it later (it triggers a new certificate).runtime.type: "node"means the Node buildpack;buildCommandis how the frontend is built (it defaults tonpm run build, so this line is optional).auth,database, andstorageare the three resource modes — see below.envdeclares your own variables by name. Secret values never go in this file; you set them withastrodock set-secretor in the dashboard.
Field reference
The full schema reference (with types and defaults for every nested field) lives on app.json & env reference. This is the quick version:
| Field | Req | Notes |
|---|---|---|
schemaVersion | yes | Always "1". |
slug | yes | ^[a-z0-9-]+$. Immutable identity. |
name | yes | Human display name. |
description | no | Shown in the dashboard. |
subdomain | yes | ^[a-z0-9-]+$. Served at <subdomain>.<base-domain>. Mutable. |
source.branch | no | Branch to deploy/watch. Default main. |
source.repoPath | no | Subdirectory to deploy from (monorepos). Default repo root. |
source.githubRepo | no | owner/repo; can also be set via CLI/dashboard. |
runtime.type | yes | node or docker. |
runtime.buildCommand | no | Node only. Default npm run build. |
runtime.dockerfile | no | Docker only. Default Dockerfile. |
auth.mode | yes | platform or public. |
database.mode | yes | internal, external, or none. |
storage.mode | yes | internal, external, or none. |
env[] | no | Declared variable names + metadata. Keys are UPPER_SNAKE_CASE and may not start with ASTRODOCK_. |
Resource modes
Each app picks how it wants a database and object storage — and whether the platform handles login. The choice is pure configuration: your code reads the same environment variables either way, so moving from the bundled database to a hosted one later is a value change, not a code change.
| Block | Modes | What you get |
|---|---|---|
database |
internal · external · none |
internal: a Postgres database on the bundled engine, auto-created and injected
as ASTRODOCK_DATABASE_URL. external: you supply your own connection
string. none: no database. |
storage |
internal · external · none |
internal: a bucket/prefix on the bundled S3-compatible store. external:
your own S3 / R2 / Spaces credentials. none: no storage. |
auth |
platform · public |
platform: Astrodock manages your users and login. public: the app
handles its own (or none). |
With internal, everything is provisioned for you. With external, the
deploy is blocked until you've set the credentials (the
required-variable gate). The details for each:
- External database — bring your own Postgres.
- External storage — bring your own S3-compatible bucket.
Adding login with platform auth
Set "auth": { "mode": "platform" } and the platform injects everything your server
needs to verify users it manages: ASTRODOCK_AUTH_URL, ASTRODOCK_APP_ID,
ASTRODOCK_APP_SECRET, and ASTRODOCK_APP_JWT_SECRET.
The flow is: your frontend posts an email + password to your own /api/login, your
server asks the platform "are these valid for me?", and on success you mint your own
session. Here's the core of it, lifted from the starter app:
app.post('/api/login', async (req, res) => {
const { email, password } = req.body || {};
// Ask the control plane to verify the credentials for THIS app.
const verifyRes = await fetch(`${process.env.ASTRODOCK_AUTH_URL}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
appId: process.env.ASTRODOCK_APP_ID,
appSecret: process.env.ASTRODOCK_APP_SECRET
})
});
if (verifyRes.status === 401) return res.status(401).json({ error: 'Invalid credentials' });
if (verifyRes.status === 403) return res.status(403).json({ error: 'No access to this app' });
if (!verifyRes.ok) return res.status(502).json({ error: 'Auth error' });
const user = await verifyRes.json(); // { userId, email, name }
// Mint your own session — here, a JWT signed with the injected secret.
const session = jwt.sign(
{ sub: user.userId, email: user.email, name: user.name },
process.env.ASTRODOCK_APP_JWT_SECRET,
{ expiresIn: '7d' }
);
res.cookie('session', session, { httpOnly: true, sameSite: 'lax', secure: true });
res.json({ user });
});
A 401 means bad password; a 403 means no access
/verify returns 403 when the credentials are valid but that user
hasn't been granted access to this app's slug. Create users and grant access in the dashboard —
see Manage users & access.
Zero extra dependencies needed
The starter calls /verify with plain fetch. There's also an
@astrodock/auth-client package that wraps exactly this call if you'd rather not
write it yourself.
Next steps
- How a deploy actually runs: The deploy lifecycle
- Ship a non-Node runtime: Dockerfile apps
- Every field and variable, precisely: app.json & env reference