Skip to content

Deployment

This page covers how a theme build works (dev vs. production), which deployment strategies are possible, and how the staging-to-live deployment system of the Update Manager works. nginx example configuration and typical pitfalls included.

Build commands

Every theme (_theme/vue-base/, _theme/projekt01/, …) has its own package.json with standard Nuxt scripts.

One-time setup:

bash
cd _theme/vue-base
npm install

The four npm scripts:

ScriptPurpose
npm run devDev server on http://localhost:3000 with hot reload
npm run buildSSR production build into .output/
npm run generateStatic build (SSG) into .output/public/
npm run previewPreview of the production build

Each theme directory has its own node_modules/ and its own build output — no global npm install at the repo root.

build vs. generate — SSR vs. SSG

CommandOutputWhen?
npm run build.output/ (Node server + client bundle)SSR deployment, needs a Node.js runtime on the host
npm run generate.output/public/ (static HTML + assets only)SSG deployment, only a static web server required (nginx, Apache, CDN)

In the CMS, npm run build is usually the right choice — the Nuxt theme runs as a Node process and fetches content dynamically via /api/pages. Pure static delivery would be possible with generate, but Pagebuilder content and session-dependent widgets would then be frozen at build time.

Output structure after npm run build

_theme/vue-base/.output/
├── nitro.json                  # Nitro server meta
├── public/                     # Static assets (client-side)
│   ├── _nuxt/                  # Bundled JS + CSS
│   │   ├── entry.*.css
│   │   └── *.woff2
│   ├── backend/                # Admin CSS/JS (copied from public/)
│   ├── favicon.ico
│   └── robots.txt
└── server/                     # Nitro server (SSR)
    ├── index.mjs               # Entry point
    └── chunks/

.output/server/index.mjs is the entry point for production: node .output/server/index.mjs starts a listen port (default 3000).

Plesk deploy example (deploy.sh)

The repo ships a real Plesk deploy script under _theme/vue-base/deploy.sh:

bash
#!/bin/bash
export PATH=/opt/plesk/node/25/bin:$PATH

cd /path/to/theme/_theme/vue-base

echo "Building Nuxt..."
npm run build >> build.log 2>&1

# Save font + CSS file names into JSON (for admin CSS isolation)
ls .output/public/_nuxt/*.woff2 .output/public/_nuxt/entry.*.css 2>/dev/null | \
    xargs -I{} basename {} | \
    python3 -c "..."

echo "Restarting App..."
mkdir -p tmp
touch tmp/restart.txt   # Plesk Passenger restart trigger

echo "Done!"

The detail to watch: fonts-manifest.json is generated after the build — it contains the hashed file names from _nuxt/ for the admin CSS isolation plugin (admin-css.client.ts). Without this manifest, the admin layout loads no fonts.

Nuxt server behind nginx

In production, the Nuxt theme is typically run behind nginx as a reverse proxy to the Node process.

Example /etc/nginx/sites-available/example.com

nginx
# SSL redirect
server {
    listen 80;
    server_name www.example.com example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name www.example.com example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Nuxt frontend (Node process on port 3000)
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";
    }

    # PHP backend for /api/* and /admin/* (incl. paths without a trailing slash)
    location ~ ^/(api|admin)(/|$) {
        proxy_pass http://127.0.0.1:8080;   # PHP Apache/FPM
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Serve the upload folder directly (for performance)
    # The actual upload path depends on the install — typically /z_uploads/
    # or a dedicated upload root. The URLs are produced server-side by files::loadURL().
    location ~ ^/z_uploads/ {
        root /path/to/cms;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

X-Forwarded-For must be forwarded

The PHP backend uses $_SERVER['HTTP_X_FORWARDED_FOR'] for IP-based rate limiting, session binding, and audit logging. Without proxy_set_header X-Forwarded-For, every backend operation only sees the local nginx IP (127.0.0.1) — rate limits then hit the proxy rather than real visitors.

Start the Node process at boot

With systemd (Debian/Ubuntu):

ini
# /etc/systemd/system/newmeta-frontend.service
[Unit]
Description=newmeta Nuxt Frontend
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/path/to/cms/_theme/vue-base
ExecStart=/usr/bin/node .output/server/index.mjs
Environment=NUXT_API_KEY=...
Environment=NUXT_PUBLIC_API_BASE=https://www.example.com/api
Environment=NUXT_PUBLIC_SITE_URL=https://www.example.com
Environment=NUXT_PUBLIC_THEME=vue-base
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Activate:

bash
systemctl daemon-reload            # once after creating/changing the unit
systemctl enable newmeta-frontend
systemctl start  newmeta-frontend
systemctl status newmeta-frontend

After changes to the .service file, run systemctl daemon-reload followed by systemctl restart newmeta-frontend — without the daemon reload, systemd keeps reading the old config.

With Plesk: no systemd unit required — Plesk Passenger manages the Node process and reacts to tmp/restart.txt as a reload trigger.

Cache rebuild as a deploy step

After every theme build, you additionally have to regenerate the PHP CSS cache so new design tokens, custom CSS, and plugin-registry.js take effect. Primary path: admin → /admin/cache → click "Clear cache" manually. Behind the button sits apcu_clear_cache() in the script cache_settings/script/cache_clear.php, plus the cache rebuild via cache_compress.php.

For automated deploy pipelines, the corresponding backend endpoint can be called directly with an API key — the exact path is install-dependent and visible in the API key panel.

What exactly happens depends on what changed:

  • Theme code only (Vue, styles): npm run build + systemctl restart newmeta-frontend is enough
  • New widget or custom override: additionally trigger the cache rebuild so plugin-registry.js is regenerated
  • Design-token change via /admin/design: the core triggers the rebuild automatically on save

Details: Nuxt aliases › When does plugin-registry.js get rebuilt?.

Staging to live via the Update Manager

The CMS ships its own staging-to-live deployment mechanism — under /admin/update (the "Deployments" area) you can configure target environments and roll out with a click.

Concept

[Staging server]  ──────→  [Production server]
                   Deploy

1. Build a ZIP of the code (excluding .git, node_modules, .nuxt, .output, .claude)
2. Take a MySQL dump of the staging DB
3. Extract files into the target directory (with a backup of config.local.php)
4. Drop the target's tables + import the dump
5. URL replace: staging.example.com → www.example.com
   (serialized-aware: unserialize → recursive replace → serialize)
6. Dispatch the `deployment.completed` webhook

Configuration

One entry per target environment in deployment_targets, with:

  • DB credentials (AES-256-CBC encrypted)
  • Target directory
  • Search/replace URL pair
  • Active flag

Details in the admin UI /admin/update → "Deployments".

7-step flow

  1. Create ZIP — from the current code
  2. DB dumpmysqldump or mariadb-dump (auto-detection)
  3. Copy files — the target system's config.local.php is preserved
  4. Clear target DB — drop every table
  5. Import DB — apply the dump
  6. URL replace + ident — serialized-aware, JSON-escaped, with updateWebsiteIdent()
  7. Finalize — status update, webhook dispatch, log entry

Exclusions

Tables like sessions, logs, queue, and cache are excluded from the dump via deployment_targets/config/deploy_exclude_tables.json — unless the target DB is empty (first-time deploy), in which case every table is imported.

Out of scope

The staging-to-live system handles PHP code + DB + media. The Nuxt theme has to be deployed separately (npm run build on the target server + app restart). Typical flow:

1. On staging: publish Pagebuilder pages, run tests
2. In the admin: /admin/update → Deployments → pick target → "Deploy"
   → PHP code + DB + media land on production
3. On the production server: cd _theme/vue-base && ./deploy.sh
   → Nuxt build + app restart

Common issues

X-Forwarded-For header missing from the nginx proxy

The backend only sees 127.0.0.1 as the IP → the rate limiter hits globally, session binding breaks, the IP audit log is useless. Always set proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for in the nginx config.

Nuxt build with the wrong NUXT_PUBLIC_API_BASE

The env variable is baked into the client bundle at build time. A production build with a staging URL causes mixed-content errors or wrong API calls. Check .env before npm run build, or set the build env explicitly: NUXT_PUBLIC_API_BASE=https://www.example.com/api npm run build.

App restart missing after the build

npm run build updates .output/, but the running Node process keeps serving the old code. systemctl restart newmeta-frontend (or touch tmp/restart.txt on Plesk) is mandatory after every build.

Cache rebuild missing after a widget change

A new widgets/*/template/index.vueplugin-registry.js has to be regenerated. Without a cache rebuild, the Pagebuilder picker shows the widget, but rendering throws Unknown widget type — because the registry doesn't know the widget_template.

Deploy during a staging test

Staging-to-live drops every table in the target and replaces them with the staging dump. If production users are entering data in parallel, that data is lost. Always deploy during maintenance windows or after an explicit freeze on production.

deploy.sh with wrong paths

The real deploy.sh in the repo has hard-coded paths (/var/www/vhosts/...). For other hosts, absolutely adapt them — otherwise the build writes into foreign directories or throws cd errors.

See also