Operations and security
Since v1.2 the base ships these operational features. All are optional: leave the variables blank and the feature switches off, so existing sites are unaffected.
Scheduled publishing
Posts, pages and products can be scheduled (enter a future publish time in the editor and click
"Schedule"). They go live on time and emit content.published: through /api/cron/dispatch
when the dispatcher runs, and otherwise on ordinary traffic (a background check at most every
5 minutes per instance that never slows a request). Without cron, an item goes live with the
first visit after its time.
Scheduled task dispatcher
Tasks configured in Admin → Scheduled tasks run on their cron expressions through
POST /api/cron/dispatch.
NUXT_CRON_SECRET=<long random string> # bearer token for the dispatcher
NUXT_CRON_TZ_OFFSET=480 # schedule timezone in minutes (default China Standard Time)
Who wakes the dispatcher:
| Deployment | How |
|---|---|
| Cloudflare Pages | deploy deploy/cron-worker (copy wrangler.toml.example, wrangler secret put CRON_SECRET, wrangler deploy); every 5 minutes by default |
| Linux / Baota | crontab or Baota scheduled task: curl -fsS -X POST -H "Authorization: Bearer $NUXT_CRON_SECRET" https://site/api/cron/dispatch |
| Many sites from one server + failure alerts | one Baota "Shell script" task per site (every 15 minutes) running deploy/cron-baota/run.sh; on failure it prints NUXTCMS_CRON_ALERT and Baota's keyword alert sends it (Feishu / e-mail). See deploy/cron-baota/README.md |
| Manual catch-up | same command with ?all=1 runs every enabled task once |
Each tick also does maintenance: task logs keep the newest 500 rows and expired rate-limit rows are purged.
Only admins can view, create and run scheduled tasks. The cron secret only goes to the site
itself: tasks whose path starts with /api/cron/ always call the site's current origin (with
Authorization: Bearer … added), and {{cronSecret}} in headers or bodies is filled in only for
the site's own origin; anywhere else it becomes empty.
Content saved in the admin by editors (non-admin users) is stripped of scripts, iframes, forms and event attributes; admins keep full HTML.
Built-in task endpoints
Create a task with one of these URLs and the header {"Authorization":"Bearer <NUXT_CRON_SECRET>"}:
| Endpoint | Purpose | Settings needed |
|---|---|---|
/api/cron/send-contact-emails | e-mails new contact-form submissions to the owner | contactNotifyEmail (falls back to companyEmail) and a working mail configuration |
/api/cron/push-search-engines | submits URLs to IndexNow (Bing, Yandex, ...) and Baidu | indexNowKey, baiduPushToken; blank channels are skipped; /{indexNowKey}.txt is served automatically |
Health probe
GET /api/health performs a database round trip and returns driver, storage, version and
latency; it answers 503 when the database is unavailable. Point Uptime Kuma, Baota or a
Cloudflare health check at it.
Human verification (Admin → Security)
The admin Security page picks the method and the protected forms (admin login, contact form, blog comments, newsletter, shop checkout):
| Method | Notes |
|---|---|
| Built-in image captcha | no third-party service or keys, works everywhere including mainland China; letters/digits or arithmetic; single-use, valid 5 minutes |
| Cloudflare Turnstile | most visitors pass without a puzzle; enter the keys or use the environment variables |
| Google reCAPTCHA | v2 checkbox or v3 score (minimum score configurable); script domain www.recaptcha.net works in mainland China |
| Off | rate limits only |
New settings must pass one live check before they are saved, so a wrong key cannot lock the admins out. Resolution order:
NUXT_CAPTCHA_PROVIDER(none | local | turnstile | recaptcha): emergency override, e.g.noneand redeploy when locked out;- the admin choice;
- the site default
runtimeConfig.public.captchaDefault(site nuxt.config orNUXT_PUBLIC_CAPTCHA_DEFAULT); - auto: Turnstile when its environment keys are set, then reCAPTCHA keys, else the built-in captcha.
A method without its keys falls back to the built-in captcha, so forms never lock up. Keys can also come from the environment (leave the admin fields blank):
NUXT_TURNSTILE_SECRET_KEY=...
NUXT_PUBLIC_TURNSTILE_SITE_KEY=...
NUXT_RECAPTCHA_SECRET_KEY=...
NUXT_PUBLIC_RECAPTCHA_SITE_KEY=...
Custom site forms: on the page use useCaptcha('contact') (the old useTurnstile() still works
and follows the active method), render its container ref, send token and call reset() after
each submit; on the server call assertCaptcha(event, token, 'contact') (the old
assertTurnstile(event, token) derives the form from the request path). Forms whose custom code
cannot render the widget yet are declared in the site's nuxt.config; they are never enforced
and shown greyed out in the admin:
runtimeConfig: { public: { captchaUnsupportedScenes: ['contact'] } }
Sites with their own CSP must allow, in script-src and frame-src,
https://challenges.cloudflare.com for Turnstile and
https://www.google.com https://www.gstatic.com https://www.recaptcha.net for reCAPTCHA. The
built-in captcha needs nothing.
Rate limiting
Login, contact, setup and view endpoints are rate limited. Counters live in the rate_limits
table, shared by every Worker isolate and Node process; without the table it degrades to
per-instance memory.
View counts
GET /api/views?kind=post&id=1 reads, POST /api/views counts one view (max 60 per IP per
minute). Data lives in content_views; content tables are untouched. Use
useViewCounter('post', () => id); the base blog and product pages already do.
Analytics
NUXT_PUBLIC_CF_ANALYTICS_TOKEN=... # Cloudflare Web Analytics, cookieless, no consent banner
Admin pages are not tracked.
Startup environment check
At startup the base checks for missing or example secrets and half-configured pairs (Turnstile,
object storage) and logs warnings; with NUXT_STRICT_ENV=1 production startup fails instead.
Database migrations
v1.2 adds two SQLite migrations, 0013_rate_limits and 0014_content_views (both
IF NOT EXISTS, safe to re-run), plus matching PostgreSQL and MySQL migrations. Deploy with
pnpm deploy:migrate, or run the two SQL files against D1 directly.