← Blog

Deploy Nuxt to Hetzner

Railyard

September 12, 2026 · 8 min read

Follow this on your own server. Start free, no card needed

You’ve got a Nuxt app and a Hetzner account, and you want the app answering at your own domain over HTTPS, without handing the whole thing to a platform that bills per request. This is the fully manual version: a real server, a Dockerfile that produces a small production image, and Caddy in front of it for certificates. By the end you’ll have Nuxt’s Nitro server running inside a container, served behind HTTPS, with a repeatable way to rebuild and restart it.

What you’ll need

  • A Hetzner Cloud account and API token.
  • A server: a CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) runs a small-to-medium Nuxt app fine at runtime; if the build itself runs out of memory on a large app, resize up to a CX33 (4 vCPU, 8 GB RAM, €8.99/month) for the build, then back down afterward, or build the image on a separate machine and just run it on the CX23.
  • A domain or subdomain with an A record you can point at the server.
  • A Nuxt app with a package.json build script, on a current Nuxt release (4.6.2 as of this writing), which needs Node 20.19+ or 22.12+ at a minimum.
  • SSH access to the server.

Step 1: Create the server

From the Hetzner Cloud console, create a server using the cx23 type (or your resized choice) on Ubuntu 24.04, add your SSH key at creation time, and note the public IP once it’s running. Install Docker and the Compose plugin over SSH:

curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin

Step 2: Point DNS at the server

Create an A record for your domain or subdomain pointing at the server’s IP, for example app.yourdomain.com. Give it a few minutes to propagate, then confirm it resolves correctly before Step 6, since Caddy needs that to request a certificate:

dig +short app.yourdomain.com

Step 3: Nuxt’s build output

Running nuxt build (or nuxi build) produces a .output directory with two parts: .output/public for static assets, and .output/server/index.mjs as the Node entry point. That entry point is Nitro’s “node-server” preset, which is Nuxt’s default preset when you haven’t configured any other deployment target, so there’s no equivalent of Next.js’s explicit output: "standalone" opt-in required here, a plain nuxt build already gives you a self-contained Node server to run.

That said, “default” means it can change if Nuxt’s defaults ever change. For a production deploy it’s worth pinning it explicitly in nuxt.config.ts so a future Nuxt upgrade can’t quietly switch your deployment target out from under you:

export default defineNuxtConfig({
  nitro: {
    preset: "node-server",
  },
})

Running node .output/server/index.mjs starts the production server directly, which is exactly what the Dockerfile below does.

Step 4: Write the Dockerfile

ARG NODE_VERSION=24-alpine

FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci; \
    elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile; \
    elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
    else echo "No lockfile found" && exit 1; fi

FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder --chown=node:node /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

This is simpler than the equivalent Next.js setup, where the standalone output needs its traced node_modules plus the public/ and .next/static folders copied in separately. Nitro’s .output/server already bundles its own minimal node_modules inside itself as part of the build, so the runner stage only has to copy the one .output directory, nothing else from the builder stage is needed at runtime. node:24-alpine is a current Node.js LTS image, comfortably above the 20.19/22.12 minimums Nuxt 4 documents today, and the smaller Alpine base keeps the final image lean since there’s no native build step happening in this stage. USER node runs the process as the non-root node user already present in the official Node images rather than root.

Step 5: Write the Compose file and Caddyfile

services:
  web:
    build: .
    restart: unless-stopped
    environment:
      NODE_ENV: production
      HOST: "0.0.0.0"
      PORT: "3000"
    expose:
      - "3000"

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - web

volumes:
  caddy-data:
  caddy-config:
app.yourdomain.com {
  reverse_proxy web:3000
}

HOST and PORT (or their NITRO_HOST / NITRO_PORT equivalents) are what Nitro’s node-server reads from the environment to decide what to bind to. Setting HOST=0.0.0.0 explicitly, rather than trusting whatever Nitro’s own default happens to be, is what makes the server reachable from the caddy container next to it, the same reasoning as the Next.js guide’s HOSTNAME=0.0.0.0, just under Nitro’s own variable names. Any value you read at runtime with useRuntimeConfig() goes in this environment: block; only variables prefixed NUXT_PUBLIC_ get inlined into the client bundle during the build, everything else stays server-only and can change without rebuilding the image.

Before deploying, validate both files locally. For the Compose file:

docker compose -f compose.yml config

And for the Caddyfile:

docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile

Both should report success before you copy them to the server.

Step 6: Deploy

docker compose up -d --build

The first run builds the image, which is the slowest part, then starts both containers and Caddy requests its certificate. Tail the logs to confirm the build and the certificate issue both succeeded:

docker compose logs -f

You should see Nitro log a line like Listening on http://0.0.0.0:3000, and Caddy’s log include certificate obtained successfully for your domain. Visit https://app.yourdomain.com and confirm the app loads over HTTPS with a valid certificate, or check it directly from the server:

curl -I https://app.yourdomain.com

For a later code change, the same command rebuilds just the image that changed and recreates that one container:

docker compose up -d --build web

This isn’t truly zero-downtime, Compose stops the old web container before starting the new one, so there’s a gap measured in however long Nitro takes to boot and pass its first request. If that gap matters for your traffic, the standard fix is running two app containers behind Caddy’s own load balancing and replacing them one at a time, which is more infrastructure than this guide covers for a single-server setup.

The shortcut: do it with Railyard

Nuxt doesn’t have its own dedicated framework entry in Railyard’s guide system, only generic “Node.js” does, because the detection underneath doesn’t need to know it’s specifically Nuxt. It reads package.json, picks your package manager from whichever lockfile is present (npm, pnpm, yarn or bun), and runs your start script as the web process once the build completes, all without you writing a Dockerfile at all, the same path a Nuxt repo goes through as any other Node.js repo. If the repo ships its own Dockerfile, like the one in Step 4, that gets used directly instead of the automatic path. Either way, the app is expected to listen on the $PORT Railyard assigns, the same requirement Step 4’s explicit HOST/PORT handling satisfies by hand.

Deploy directly: /deploy/nodejs-to-hetzner. See /docs/node for the Node.js framework reference, which covers how the build and start detection works for any Node-based repo, Nuxt included.

Railyard’s Free plan covers 1 server plus a database server and 3 apps with no card required, Pro is $19/month per team, and Team is $49/month per team.

Troubleshooting

The build fails with the process getting killed partway through, no clear error. This is almost always out-of-memory during the build on a small server; either resize up temporarily as mentioned in “What you’ll need,” or add swap as a cheaper stopgap (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).

The app builds and starts, but the page never loads, Caddy times out. Check that HOST=0.0.0.0 actually made it into the running container (docker compose exec web env); a server listening only on localhost inside its own container network namespace is unreachable from the caddy service even though both containers are up. This is the single most common mistake with a Nitro node-server deploy specifically, since Nitro’s own defaults aren’t guaranteed to bind every interface.

HTTPS never issues, Caddy keeps logging a timeout against Let’s Encrypt. Confirm the DNS A record from Step 2 is actually resolving to this server’s IP from outside your network, and that ports 80 and 443 are open in Hetzner’s firewall as well as any ufw rules on the box itself, both have to allow inbound traffic for the ACME challenge to complete.

Port 3000 (or 80) is already in use on the host. Something else is bound to it already, commonly a default nginx install left over from a base image, or a leftover container from an earlier attempt. sudo ss -tlnp | grep ':80\|:3000' shows what’s holding it; this guide’s Compose file doesn’t publish 3000 to the host at all (it’s only exposed to the caddy service), so a conflict here usually points at port 80 or 443 instead.

An environment variable shows the old value after you changed it. Anything read with useRuntimeConfig() is resolved when the container starts, so changing it and restarting the container is enough, no rebuild needed. Anything prefixed NUXT_PUBLIC_, by contrast, gets inlined into the client-side JavaScript during the build itself, so changing one of those requires docker compose up -d --build web again, a restart alone does nothing for it.

FAQ

Do I need to set nitro: { preset: "node-server" } explicitly, or is the default fine? The default is functionally fine, node-server is already what you get with no configuration. Pinning it explicitly is a small amount of insurance for production so a future Nuxt release changing its default preset doesn’t change your deployment target along with it.

Can I use pnpm, yarn or bun instead of npm? Yes, the Dockerfile in Step 4 already branches on whichever lockfile is present, no changes needed beyond having that lockfile committed. (Add a bun.lockb branch if you use Bun specifically, the example above covers the three most common lockfiles.)

What about Nuxt’s server routes and API routes, do they run in this same container? Yes, anything under server/api or server/routes in your Nuxt app gets bundled into the same .output/server/index.mjs and runs in the same process as your pages, there’s no separate backend to deploy unless you’ve deliberately split one out.

Is this safe to run with multiple instances behind a load balancer later? Mostly, the Nitro node-server is stateless by default, so running several containers behind Caddy’s load balancing works without special configuration. The usual caveat applies if your app keeps any in-memory state across requests (rate limiting, in-process caching), which would need to move to something shared like Redis once you’re running more than one instance.

How is this different from deploying to Vercel or Netlify? Functionally your app runs the same code either way, Nuxt’s build output is portable by design. What you’re trading is a managed build pipeline and edge network for owning the server, the Dockerfile, and the certificate yourself, which is cheaper at steady traffic and more work at the edges (scaling, image hosting, preview environments).

The Dockerfile and Compose file here don’t change much between Nuxt projects, the part that actually varies is whatever your app reads from the environment at runtime, worth double-checking against this list before the first real deploy.

Why people pick Railyard for this

  • Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
  • One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
  • One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
  • Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
  • Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.

Deploy this repo without the Dockerfile

Paste the repo URL into Railyard and it builds, ports and env vars are detected for you on a Hetzner server.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.