Reference

Deployment

A multi-app project doesn't deploy as one unit — each app in devora.config.ts maps to its own platform project (Vercel), site (Netlify), or process (self-hosted/Docker). This is inherent to how those platforms work, not a devora.js convention: a marketing site, dashboard, and admin panel are three independently deployable things, each repeating the same steps below.

What's verified

The Vercel and Netlify functions devora build --adapter generates were run outside the framework's monorepo (real pages, login, CSRF, a CommonJS dependency), devora start and the Docker image were run locally, and the maintainer has deployed earlier versions (0.3.1 and before) to both Vercel and Netlify. A live deployment of 0.3.5 on either platform has not been verified. Container platforms (Cloud Run, App Runner, Container Apps) haven't been tested.

1. Push the project to GitHub

Both Vercel and Netlify deploy by connecting to a git repository — do this once, first:

git init                                            # skip if already a git repo
git add -A && git commit -m "initial commit"
gh repo create <your-repo> --private --source=. --push
# or, without the GitHub CLI:
#   create the repo on github.com first, then:
git remote add origin https://github.com/<you>/<your-repo>.git
git push -u origin main

Scaffolded projects don't include a CI workflow. Worth adding one that runs npm ci, npm run typecheck and npx devora build --adapter=vercel (or netlify) for each app: the adapter build runs the same preflight checks as the platform, so a missing session store or a misplaced api/ folder fails in CI instead of on the platform.

2. Configure a session store (apps with login)

A session secret alone is not enough

Since @devorajs/core 0.3.0, a production server for any app with auth: "shared" or "isolated" refuses to start until shared.sessions.store is set in devora.config.ts — even with DEVORA_SESSION_SECRET set. It fails at boot, not on the first login:

Error: [devora] no session store configured. Set shared.sessions.store in devora.config.ts — a path to a module whose default export is a SessionStore backed by your database (...), or "memory" to explicitly accept in-process sessions (single long-lived server only — not serverless).

devora dev never shows this error: it falls back to an in-memory store instead, so a project can work locally and still fail on its first deploy. Freshly scaffolded projects ship with the setting commented out. Pick a value based on where the app runs:

Targetshared.sessions.store
Vercel, Netlify (serverless)A path to your own store module, backed by a database every function instance can reach (Postgres, Redis, …). Not "memory", and not a SQLite file on the function's own disk — each instance would have its own copy, and users would be logged out at random.
Docker / VPS, one devora start process"memory" works, but every restart or redeploy signs everyone out. Use a store module to keep sessions across restarts or to run more than one instance.
// devora.config.ts
shared: {
  core: "packages/core",
  backend: "packages/backend",
  auth: "shared",
  sessions: { store: "packages/backend/sessionStore.ts" }, // or "memory"
},

The store is three methods (get/set/delete) over your own database — Security model → Sessions has a complete working example. It's read at build time and bundled into each app's server output (the Vercel and Netlify functions included), so it's committed code, not a dashboard setting; whatever connection string the store reads (e.g. DATABASE_URL) does need to be set in each platform's environment variables. Apps with auth: "none" skip this step entirely.

3. Deploying to Vercel Vercel

Keep API routes out of a top-level api/ folder

api/ at the root of a Vercel project is Vercel's own functions folder. With the Root Directory set to apps/<name>, Vercel turns every file in apps/<name>/api/ into a separate function on top of the one Devora builds (and can hit the Hobby plan's function limit), or type-checks it with its own settings. Apps created by create-devora/devora add already use apiDir: "endpoints". For an older app, move the folder and set apiDir in app.config.ts; URLs stay under /api. The preflight check warns when it finds an api/ folder there.

  1. In the Vercel dashboard: Add New… → Project, import the repo you just pushed.
  2. Set Root Directory to apps/<name> for the app you're deploying.
  3. Leave Build and Output Settings alone — each app already ships a committed vercel.json with "framework": null (disables Vercel's zero-config Vite detection, which would otherwise run a plain vite build and fail) and a buildCommand that runs devora build --adapter=vercel for that app. Nothing in the dashboard needs changing.
  4. If the app's auth mode (devora.config.ts) is "shared" or "isolated": under Settings → Environment Variables, add DEVORA_SESSION_SECRET (shared) or DEVORA_SESSION_SECRET_<APPNAME> (isolated — uppercase app name), a random value (openssl rand -base64 32 works well). Also add whatever your session store module needs to reach its database — and make sure shared.sessions.store is set and committed (step 2); without it the function fails on every request. An app with auth: "none" needs none of this.
  5. Deploy. Changing an env var afterward needs a redeploy to take effect.
  6. Repeat as a separate Vercel project, once per app.

What the build does on Vercel, so its log makes sense:

  • Runtime. The function runtime isn't hardcoded (it used to be nodejs20.x, which Vercel now rejects). It's vercel: { runtime: "nodejs22.x" } from app.config.ts if set, else the Node major running the build, else the newest one engines.node allows, else nodejs24.x. The build prints which one and why; an engines.node no supported runtime satisfies fails the build.
  • Preflight. Before building it checks the session secret and store for the app's auth mode (a missing secret is an error when building on Vercel), the runtime, and the api/ folder above.
  • CommonJS dependencies such as pg work inside the bundled function (they used to fail with Dynamic require of "events" is not supported). Packages with native addons haven't been verified in the function.
  • Proxy headers. Inside the function trustProxy defaults to true (only Vercel's edge can reach it), so ctx.ip, ctx.url and secure: "auto" cookies see the real client.
  • Codegen. A clean checkout doesn't have generated files (a Prisma client, compiled CSS). List those commands in prebuild in app.config.ts; they run before every devora build/deploy.

Root Directory = the repo root instead (if you can't move an api/ folder yet): leave Root Directory empty, set the Framework Preset to Other and the build command to ./node_modules/.bin/devora build --app=<name> --adapter=vercel --out=.vercel/output per project. The repo root itself then must not have an api/ folder.

4. Deploying to Netlify Netlify

Same shape as Vercel, with a few platform-specific gotchas worth knowing up front:

  1. In the Netlify dashboard: Add new project → Import an existing project, pick the repo.
  2. Set Base directory to apps/<name>.
  3. Each app ships a committed netlify.toml with the real build command, publish directory, and an SSR redirect already set — in principle nothing else needs configuring.

    Dashboard settings silently override netlify.toml

    Netlify's own dashboard Build settings take precedence over netlify.toml when both are set. If the site was ever auto-detected before netlify.toml existed (or a "Package directory" got set at any point), that stored setting silently overrides the committed file on every future build — confirmed directly: a site with a stray Package directory value produced a build log reading No config file was defined: using default values instead of Config file: .../netlify.toml, and the deployed function 404'd on every route as a result. Fix once, per site: Project configuration → Build & deploy → Build settings → Configure, and clear Package directory entirely (leave it blank) — also clear Build command/Publish directory, or set them to match netlify.toml exactly, if either was set:

    Build command:      cd ../.. && ./node_modules/.bin/devora build --app=<name> --adapter=netlify
    Publish directory:   dist/client
    Package directory:   (leave blank)
  4. Environment variables — same shared/isolated/none rule as Vercel above, under Environment variables in this site's own settings, including your session store's database connection. Same requirement too: a database-backed shared.sessions.store (step 2), never "memory" on Netlify's functions.
  5. Deploy, then check the build log. It should read build.command from netlify.toml, not Build command from Netlify app — the latter means the dashboard override from step 3 is still active.
  6. If the site builds and deploys but the live URL returns a runtime error rather than your page, check Cloud compute → Functions → ssr for the actual error (the browser only ever shows a generic 500). Netlify's function packager only bundles what it can statically trace from the function's own imports, plus whatever netlify.toml's included_files explicitly lists — several real, previously-undiscovered gaps here were found this way against a real live deploy:
    • SyntaxError: Cannot use import statement outside a module — the function's own package.json ({"type":"module"}, written by the adapter so Node treats the built output as real ESM) wasn't reaching the deployed bundle.
    • Error: Cannot find package 'react' — the adapter vendors real, resolved react/react-dom package trees into the function's own node_modules; same tracer blind spot.
    Both are already fixed in every scaffolded app's committed netlify.toml (included_files lists routes/**, the app's API folder (endpoints/** in new apps — it must match apiDir; the preflight check warns if it doesn't), dist/**, package.json, and node_modules/**) — this is documented here in case a future platform change reintroduces the same class of gap: anything the function reads from disk at runtime, rather than statically imports, has to be named in that list explicitly, or it silently never reaches the deployed function.
  7. Repeat as a separate Netlify site, once per app.

For both platforms, devora deploy --adapter=vercel|netlify (see the CLI reference) is an optional convenience for pushing from the CLI once an app is linked with the platform's own vercel link/netlify link — it's not required if you're already deploying on every git push through the dashboard integration described above.

5. Docker Docker

Scaffolded projects don't include a Dockerfile; the framework repo has a working one (Dockerfile, docker-compose.yml) to copy and adapt — it runs devora start --app=$APP_NAME and listens on PORT (4173 unless you set it). Each app builds and runs as its own container image, one app per image via a build arg:

docker build --build-arg APP_NAME=marketing -t devora-marketing .
docker run -p 4173:4173 devora-marketing

# shared/isolated apps need their session secret passed in, AND
# shared.sessions.store set in devora.config.ts before the image is built
docker run -p 4173:4173 -e DEVORA_SESSION_SECRET=... devora-dashboard

# all apps together, via docker compose
cp .env.example .env
docker compose up --build

Under the hood this runs the same self-hosted Node adapter described below — Docker isn't a separate deployment target with its own adapter, just a way to package and run it. The session store is baked in at build time (step 2): "memory" is acceptable for a single container, but every container restart signs everyone out, and two replicas won't see each other's sessions.

Container platforms untested

Cloud Run, AWS App Runner/Fargate and Azure Container Apps run the same image. None of them has been tested; this is what the framework needs from them:

  • Port. The platform sets or lets you choose PORT; devora start reads it. Leave HOST unset so the platform's router can reach the process.
  • HTTPS at the load balancer. TLS ends in front of the container. Session cookies are Secure by default, which is right for an HTTPS public URL; set trustProxy: true in app.config.ts so ctx.url, ctx.ip and secure: "auto" see the real scheme and client.
  • A shared session store. Several instances, replaced at will: "memory" doesn't work. Point shared.sessions.store at your database or Redis.
  • Secrets in the platform's secret settings, not in the image.
  • isr caches on each instance's own disk.

An Oracle Cloud VM, or any VM, is the VPS setup below.

6. Self-hosted VPS VPS

For a bare server, build each app you want to run, start them, then generate a reverse proxy config from the domains already declared in devora.config.ts — no hand-written nginx/Caddy config needed:

npm run build                              # builds every app (or: npm run build -- --app=dashboard)
npm run start                              # serves all built apps, sequential ports from 4173
npx devora generate:proxy --target=nginx   # or --target=caddy
# install the generated config for your distro, then reload nginx/caddy

Run these from the project root after npm install — the devora binary lives in the project's node_modules, not on your PATH. generate:proxy has no npm script, so it goes through npx, which is only safe inside the project (see Getting started). As with Docker, any app with login needs shared.sessions.store set before building (step 2) — npm run start exits with the error above otherwise.

Bind each app to loopback so only the proxy reaches it: HOST=127.0.0.1 npm run start (PORT sets the first app's port). Session cookies are Secure by default in production, so logins over plain HTTP won't stick until the proxy serves HTTPS — the server logs a warning when it sends a Secure cookie over what looks like plain HTTP. For a mixed setup set shared.sessions.cookie.secure: "auto" in devora.config.ts and trustProxy: true in each app (the generated configs send X-Forwarded-Proto); false is for a deliberately plain-HTTP deployment. See Security model.

The generated nginx config listens on plain HTTP only — issuing a real TLS certificate needs a real, DNS-resolving domain, so the documented next step there is certbot --nginx -d <domain>. Caddy needs no such step: automatic HTTPS via ACME is its default behavior for any domain it can prove ownership of. To keep devora start running across reboots/crashes, a systemd unit template is available as a starting point to adapt to your own host, rather than a drop-in guarantee.

Static files and pre-rendered pages in devora start

devora start (and a generated function, for the requests that reach it) serves built assets, public files and ssg pages from memory:

  • Limits: 64 MB in total for held files and their compressed copies, 2 MB per file. A larger file is streamed from disk with its real Content-Lengthand is never held in memory; once the 64 MB is used, further files are streamed too.
  • Freshness: a held file is checked against the disk at most once a second, so a rebuild into a running server shows up within a second. Changed route code (dist/server) still needs a restart.
  • Headers: a strong ETag and If-None-Match → 304; HEAD; brotli or gzip for text types when the client sends Accept-Encoding (made once per file); one year and immutable for hashed /assets/, one hour for public files, and max-age=0, must-revalidate for pre-rendered HTML.
  • Methods: anything other than GET and HEAD to a file or a pre-rendered page is a 405.

isr pages aren't held this way; they are re-rendered when stale and written back to disk.