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:

ShapeWhat's in the folderHow 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:

  1. Bind process.env.ASTRODOCK_PORT

    The platform tells your server which port to listen on. Don't hardcode 3000. An unprefixed PORT alias is also provided for convenience, but prefer the explicit one.

    const PORT = process.env.ASTRODOCK_PORT || process.env.PORT || 3000;
    app.listen(PORT);
  2. Put every route under /api

    Caddy only proxies /api/* to your server; everything else is the static frontend. A route at /login will 404 — it has to be /api/login.

  3. Answer GET /health with a 200

    After every deploy the platform probes your server. A 200 from /health marks the deploy healthy. (This is the one route that lives outside /api.)

    app.get('/health', (req, res) => res.json({ status: 'ok' }));
  4. 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" }
  ]
}

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:

FieldReqNotes
schemaVersionyesAlways "1".
slugyes^[a-z0-9-]+$. Immutable identity.
nameyesHuman display name.
descriptionnoShown in the dashboard.
subdomainyes^[a-z0-9-]+$. Served at <subdomain>.<base-domain>. Mutable.
source.branchnoBranch to deploy/watch. Default main.
source.repoPathnoSubdirectory to deploy from (monorepos). Default repo root.
source.githubReponoowner/repo; can also be set via CLI/dashboard.
runtime.typeyesnode or docker.
runtime.buildCommandnoNode only. Default npm run build.
runtime.dockerfilenoDocker only. Default Dockerfile.
auth.modeyesplatform or public.
database.modeyesinternal, external, or none.
storage.modeyesinternal, external, or none.
env[]noDeclared 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.

BlockModesWhat 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:

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

Related