Deploy a Nuxt app

Deploy a Nuxt app on Lucity. Nitro output, runtime config, and a database, with no preset to pick.

Nuxt is the easiest of the JavaScript frameworks to deploy here, because its default build output is already a plain Node server. There is no platform preset to choose and no adapter to install.

npx nuxi@latest init my-app

Push that to GitHub, connect it as described in the quickstart, and it runs. If you would rather not scaffold your own, zeitlos/nuxt-example(opens in a new tab) is exactly the above, deployed.

The start command, and where it comes from

The build runs npm run build, which produces .output/. For the start command, detection looks for a start script first. A stock Nuxt project has none, so it falls through to the Nitro default:

node .output/server/index.mjs

That is the same command nuxt preview wraps, and it reads the PORT Lucity injects. Nothing to configure.

Add a start script and it wins. That is the hook to use if you want to run migrations before the server, or pass Node flags:

{
  "scripts": {
    "start": "node --max-old-space-size=1024 .output/server/index.mjs"
  }
}
Commit your package-lock.json. A Nuxt project without one failed in our own build with npm error Cannot read properties of null (reading 'edgesOut'), an npm resolution failure that never shows up locally because your node_modules is already there. The lockfile also makes the build reproducible, which is the real reason to commit it.

Leave the Nitro preset alone

Nitro can target Vercel, Netlify, Cloudflare Workers and a dozen other hosts, and every one of those presets produces output that only runs on that host. The default preset produces a Node server, which is what a container wants.

So: do not set nitro.preset in nuxt.config.ts, and remove it if you inherited a config that has one. That single line is the difference between a deployment that works anywhere and one that is pinned to a vendor. Lucity has nothing to add here, and that is the point.

Runtime config and variables

Nuxt splits configuration in a way that maps neatly onto how variables behave.

runtimeConfig values are read at runtime, so they behave like any other variable: change one, the service rolls out in seconds with the same image and the new value is live.

runtimeConfig.public values are exposed to the browser and inlined during the build. Changing one needs a new build, not just a rollout. If a public value differs between environments, that means one build per environment, which is worth knowing before you rely on it.

The environment variable names follow Nuxt's convention: NUXT_API_SECRET fills runtimeConfig.apiSecret, and NUXT_PUBLIC_SITE_URL fills runtimeConfig.public.siteUrl.

Server routes and a database

Nuxt server routes run in the same container, so a PostgreSQL database is just a connection away. Link DATABASE_URL to the database's fqdn-uri, then use it from server/api/:

// server/api/posts.get.ts
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export default defineEventHandler(async () => {
  const { rows } = await pool.query('SELECT id, title FROM posts ORDER BY id DESC LIMIT 20');
  return rows;
});

Uploads belong in a bucket rather than on the container's disk, which resets on every rollout.

If you are building a static site

nuxt generate produces a static site instead of a server, and Lucity will happily serve it, but detection keys on the build script. Point your build script at nuxt generate and set a start script that serves .output/public, or keep the server build. Mixing the two, generating in the build and then starting a server, gives you the costs of both.

Next steps