Run a server-rendered Nuxt app on your own VPS, with an API route, working browser assets, and a release ID you can check after each update. This recipe uses Nitro's node-server preset. It is for applications that need a running server; a fully prerendered site can use static hosting instead.
The manual Docker route works without a QuickDeploy license. QuickDeploy is an optional way to automate the source upload, Nixpacks build, container setup and HTTPS configuration after you have checked your app and server.
What the sample verifies
The sample pins Nuxt 4.5.2 with a dependency lockfile resolving Nitro 2.13.4, Vue 3.5.43 and Vite 8.3.3. Local production builds use Node 24.18.0 and npm 11.16.0; the Dockerfile pins Node 24.18.0. Nixpacks 1.39.0 uses Node 24.21.0 from a pinned Nix archive. These are tested versions, not a claim that every Nuxt app is compatible.
Checks cover server-rendered HTML, a dynamic API route, static and JavaScript assets, public/private runtime configuration, and updating and restoring configuration without rebuilding. Docker and Nixpacks images are checked behind a separate Caddy proxy using a trusted local certificate authority. Public DNS, public certificate issuance and a licensed QuickDeploy SSH deployment are separate checks that have not been performed for this sample.
Before you start
Use a compatible Ubuntu VPS with Docker Engine, Docker Compose and SSH key access. Point a domain at the server and make ports 80/443 available to your reverse proxy. Follow the server setup guide and compatibility checklist.
Use a separate test project and domain. If another proxy already serves applications on the host, integrate with that proxy instead of replacing it. QuickDeploy manages a shared Caddy deployment and can recreate it on a first deployment; use an isolated test VPS when existing services must stay untouched.
Start with the sample
Download the sample and extract it into a new directory:
curl --fail --location https://quickdeploy.dev/examples/nuxt.tar.gz -o nuxt.tar.gz &&
tar -xzf nuxt.tar.gz &&
cd nuxt
Archive checksums identify the downloadable version. It includes the app, dependency lockfile, Dockerfile, .env.example, explicit nixpacks.toml, and an executable verification script. Keep it separate from your own app.
For a local check with Node 24:
npm ci --include=dev
npm run build
npm run verify
The verification script starts production servers on temporary loopback ports and closes them afterward. It checks the initial configuration, changes the public label and release ID without rebuilding, then restores the first configuration. It also checks that a private test value does not appear in the API response, rendered HTML or public build output. This is a check of this sample, not a security audit of your application.
Build a Node server, not a static export
The sample's nuxt.config.ts explicitly selects the server preset:
export default defineNuxtConfig({
nitro: { preset: 'node-server' },
runtimeConfig: {
apiSecret: '',
releaseId: 'local',
public: { siteLabel: 'Nuxt VPS sample' },
},
})
npm run build runs nuxt build. Start the result with node .output/server/index.mjs, not the development server. Preserve the entire .output directory: it contains the server, bundled dependencies and public assets. See Nuxt deployment.
The Dockerfile builds in one stage, then copies only .output into a runtime stage and runs as the unprivileged node user. Set NODE_ENV=production, NITRO_HOST=0.0.0.0, and NITRO_PORT=3000 in the container. The host must be reachable from Caddy's container; loopback-only binding inside the app container will not serve the proxy.
Set runtime variables deliberately
- Release label:
releaseIdusesNUXT_RELEASE_ID. The sample intentionally returns it from the health route, so use a non-sensitive label. - Private server value:
apiSecretusesNUXT_API_SECRET. The sample reports only whether it is set, never its value. - Public site label:
public.siteLabelusesNUXT_PUBLIC_SITE_LABEL. It appears in the page and client payload; it must not contain secrets.
Nuxt's production server does not automatically read your .env file. Supply variables through your process environment or container configuration. A variable such as API_SECRET is not the matching override for the sample's apiSecret key; use NUXT_API_SECRET. Never put a secret in runtimeConfig.public or a NUXT_PUBLIC_* value. See Nuxt runtime configuration.
The health route calls useRuntimeConfig(event), returns a fresh timestamp and sends Cache-Control: no-store. It deliberately returns only a boolean for the private configuration. Avoid returning the complete runtime config object from your own routes.
Run the Docker image locally
docker build -t nuxt-recipe:1 .
docker run --rm --name nuxt-recipe-local \
-p 127.0.0.1:3100:3000 \
-e NUXT_RELEASE_ID=first-deploy \
-e NUXT_PUBLIC_SITE_LABEL='My Nuxt VPS' \
nuxt-recipe:1
Use a free host port if 3100 is occupied. Open http://localhost:3100, /api/health, and /verification.txt. The page should show the site label and first-deploy; the static file should contain quickdeploy-nuxt-static-asset. With no NUXT_API_SECRET supplied, the health response correctly reports privateConfigPresent: false. Stop this test with Ctrl+C.
Build for the VPS architecture when it differs from your computer. For example, docker buildx build --platform linux/amd64 --load -t nuxt-recipe:1 . creates an amd64 image. This recipe's checks covered Linux amd64 only.
Put the app behind Caddy
Use the existing Docker and Caddy guide for image transfer, the shared web network and the proxy service. After loading the image on the VPS, add the sample as a separate Compose project:
services:
app:
image: nuxt-recipe:1
container_name: nuxt-recipe
environment:
NODE_ENV: production
NITRO_HOST: 0.0.0.0
NITRO_PORT: "3000"
NUXT_RELEASE_ID: first-deploy
NUXT_PUBLIC_SITE_LABEL: My Nuxt VPS
expose:
- "3000"
networks:
- web
restart: unless-stopped
networks:
web:
external: true
Create the external network if needed and attach Caddy to it as described in that guide. Add the following site to the proxy's existing Caddyfile, replacing the example domain:
nuxt.example.com {
reverse_proxy nuxt-recipe:3000
}
Start the app with docker compose up -d, then validate and reload the existing Caddy configuration. The app does not publish port 3000 to the internet; Caddy handles the public connection. Verify DNS and certificate issuance on your actual test domain.
Use QuickDeploy for repeat deployments
QuickDeploy builds with Nixpacks, not this Dockerfile. The supplied nixpacks.toml chooses Node 24 and OpenSSL explicitly, installs the locked dependencies, runs npm run build, then starts the Node output with npm start. Both image build paths were tested; that does not substitute for a full licensed SSH deployment.
After checking compatibility and installing and configuring QuickDeploy, create .env.production in the sample directory:
NODE_ENV=production
NITRO_HOST=0.0.0.0
NITRO_PORT=3000
NUXT_RELEASE_ID=first-deploy
NUXT_PUBLIC_SITE_LABEL=My Nuxt VPS
QuickDeploy's deployment code supplies the uploaded production environment to the container. Add private values only when your application needs them; keep them out of source control and public configuration.
On an isolated test VPS, deploy with an unused project name:
quickdeploy push --domain nuxt.example.com --project nuxt-recipe --port 3000
Keep the command attached to your terminal so a server-side error can be reported. This sample's full QuickDeploy SSH path remains unverified.
Check the release and recover the previous configuration
curl --fail --show-error https://nuxt.example.com/api/health
curl --fail --show-error https://nuxt.example.com/verification.txt
Open the page and confirm the expected release and site label. Check its browser assets and a real application action when adapting the sample. A successful homepage response does not prove that authentication, database access or every application route works.
Change NUXT_RELEASE_ID to second-deploy and change NUXT_PUBLIC_SITE_LABEL. On the manual path, update Compose and run docker compose up -d so the container is recreated with the new environment; a simple container restart does not replace its configured environment. These runtime changes do not require rebuilding this sample. On the QuickDeploy path, update .env.production and repeat the same push command, which runs QuickDeploy's normal build/deploy workflow.
Keep the previous image, source revision and configuration. To recover the manual deployment, restore the previous image tag and environment, recreate the container, and repeat the checks. The sample tests restoring configuration on the same image; they do not prove automatic rollback of an application release or a database migration. QuickDeploy's process check is not an HTTP readiness or zero-downtime guarantee.
If the result is wrong
- The release or label is unchanged: check the exact
NUXT_variable name, confirm the new environment reached the running process, and recreate the container. The sample verification demonstrates changing these values without a rebuild. - The private-config flag is false: the sample's default is empty. Supply
NUXT_API_SECRETthrough the environment if you want to exercise that check; never expose the value in a response. - The static file or JavaScript is missing: confirm the whole
.outputtree was copied, including.output/public. The sample tests both the static file and assets referenced by the page.
For proxy connectivity and certificate diagnostics, follow the existing Docker/Caddy guide. Database persistence and migrations are a separate concern; the existing PostgreSQL walkthrough uses Next.js/Prisma, not a verified Nuxt database sample.