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:
| Target | shared.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.
- In the Vercel dashboard: Add New… → Project, import the repo you just pushed.
- Set Root Directory to
apps/<name>for the app you're deploying. - Leave Build and Output Settings alone — each app already ships a committed
vercel.jsonwith"framework": null(disables Vercel's zero-config Vite detection, which would otherwise run a plainvite buildand fail) and abuildCommandthat runsdevora build --adapter=vercelfor that app. Nothing in the dashboard needs changing. - If the app's
authmode (devora.config.ts) is"shared"or"isolated": under Settings → Environment Variables, addDEVORA_SESSION_SECRET(shared) orDEVORA_SESSION_SECRET_<APPNAME>(isolated — uppercase app name), a random value (openssl rand -base64 32works well). Also add whatever your session store module needs to reach its database — and make sureshared.sessions.storeis set and committed (step 2); without it the function fails on every request. An app withauth: "none"needs none of this. - Deploy. Changing an env var afterward needs a redeploy to take effect.
- 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'svercel: { runtime: "nodejs22.x" }fromapp.config.tsif set, else the Node major running the build, else the newest oneengines.nodeallows, elsenodejs24.x. The build prints which one and why; anengines.nodeno 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
pgwork inside the bundled function (they used to fail withDynamic require of "events" is not supported). Packages with native addons haven't been verified in the function. - Proxy headers. Inside the function
trustProxydefaults totrue(only Vercel's edge can reach it), soctx.ip,ctx.urlandsecure: "auto"cookies see the real client. - Codegen. A clean checkout doesn't have generated files (a Prisma client, compiled CSS). List those commands in
prebuildinapp.config.ts; they run before everydevora 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:
- In the Netlify dashboard: Add new project → Import an existing project, pick the repo.
- Set Base directory to
apps/<name>. - Each app ships a committed
netlify.tomlwith 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.tomlwhen both are set. If the site was ever auto-detected beforenetlify.tomlexisted (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 strayPackage directoryvalue produced a build log readingNo config file was defined: using default valuesinstead ofConfig 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 matchnetlify.tomlexactly, if either was set:Build command: cd ../.. && ./node_modules/.bin/devora build --app=<name> --adapter=netlify Publish directory: dist/client Package directory: (leave blank)
- 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. - Deploy, then check the build log. It should read
build.command from netlify.toml, notBuild command from Netlify app— the latter means the dashboard override from step 3 is still active. - 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'sincluded_filesexplicitly 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 ownpackage.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, resolvedreact/react-dompackage trees into the function's ownnode_modules; same tracer blind spot.
netlify.toml(included_fileslistsroutes/**, the app's API folder (endpoints/**in new apps — it must matchapiDir; the preflight check warns if it doesn't),dist/**,package.json, andnode_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. - 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 startreads it. LeaveHOSTunset so the platform's router can reach the process. - HTTPS at the load balancer. TLS ends in front of the container. Session cookies are
Secureby default, which is right for an HTTPS public URL; settrustProxy: trueinapp.config.tssoctx.url,ctx.ipandsecure: "auto"see the real scheme and client. - A shared session store. Several instances, replaced at will:
"memory"doesn't work. Pointshared.sessions.storeat your database or Redis. - Secrets in the platform's secret settings, not in the image.
isrcaches 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
ETagandIf-None-Match→ 304;HEAD; brotli or gzip for text types when the client sendsAccept-Encoding(made once per file); one year andimmutablefor hashed/assets/, one hour for public files, andmax-age=0, must-revalidatefor 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.