niblr

Self-Hosting Guide

Deploy Niblr on your own infrastructure — a VPS, Docker host, or any cloud that is not Vercel. Covers wildcard DNS, reverse proxy, TLS, storage, and env vars.

This guide covers deploying Niblr on your own infrastructure — a VPS, Docker host, bare-metal server, or any cloud provider that is not Vercel.


Overview

The app is a standard Next.js application backed by PostgreSQL (via Payload CMS) and optionally an S3-compatible object store. Any Linux host that can run Node 20+ can run it. The main things to handle manually are:

  1. Wildcard DNS for tenant subdomains — not needed in single-store mode, see below.
  2. A reverse proxy to terminate TLS and forward traffic.
  3. Database migrations before first start.
  4. Replacing Vercel-specific features (cron, auto domain attach).

Running one store, not a platform? Skip most of this. Single-store mode collapses the app to one shop on one domain — no wildcard DNS, no subdomains, no platform surface. It is the mode most self-hosters want.


Single-store mode

Niblr is multi-tenant by default: many stores from one deploy, each on its own subdomain. That is the right shape for the hosted service and for agencies, and the wrong shape for one merchant running one shop — it means wildcard DNS, a wildcard TLS certificate, and an admin full of platform controls that will never apply.

Setting one environment variable turns all of that off:

NEXT_PUBLIC_SINGLE_TENANT=true

With it set:

  • Your storefront serves at the domain root. Every request is rewritten to the single store, so yourdomain.com is the shop. No store.yourdomain.com, no host parsing.
  • No wildcard DNS or wildcard certificate. A single A record and an ordinary Let's Encrypt certificate are enough. The whole Wildcard DNS section below becomes optional.
  • Platform surface disappears. Tenant switching, signup, billing and the operator dashboard are hidden from the admin. You get a store admin, not a platform admin.

The store is provisioned on first boot, so there is nothing to click through:

NEXT_PUBLIC_SINGLE_TENANT=true
SINGLE_TENANT_ADMIN_EMAIL=you@yourdomain.com
SINGLE_TENANT_ADMIN_PASSWORD=<a long random password>

# Optional
NEXT_PUBLIC_SINGLE_TENANT_SLUG=store   # internal slug, never appears in URLs
SINGLE_TENANT_STORE_NAME="My Store"

On first start the app creates the store and one tenant-admin user, then logs what it made. Provisioning is idempotent, so a boot that half-completed heals itself on the next one rather than needing a manual fix.

Set this before the build, not after. Next.js inlines NEXT_PUBLIC_* values at build time, so flipping NEXT_PUBLIC_SINGLE_TENANT on an already-built image has no effect. Rebuild.

Single-store mode is a deployment shape, not a lesser edition: it is the same code, the same MIT licence, and the same feature set minus the parts that only mean something when you are hosting other people's shops. Multi-store hosting is the paid capability; running your own shop is free forever.


Wildcard DNS + Reverse Proxy

Skip this whole section if you set NEXT_PUBLIC_SINGLE_TENANT=true above — a single A record for your domain is all you need.

DNS

At your domain registrar, add:

A    yourdomain.com        → <your-server-IP>
A    *.yourdomain.com      → <your-server-IP>

or equivalently with a CNAME for the wildcard if your registrar supports it. Wildcard DNS is required so that store-a.yourdomain.com, store-b.yourdomain.com, etc. all reach your server.

Reverse proxy (nginx example)

The Next.js process listens on a local port (default 3000). nginx (or Caddy) sits in front and:

  • Terminates TLS (Let's Encrypt wildcard cert).
  • Forwards all traffic — both subdomain requests and custom-domain requests — to the Next.js process.

nginx config snippet:

server {
    listen 443 ssl;
    server_name yourdomain.com *.yourdomain.com;

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

    location / {
        proxy_pass         http://127.0.0.1:3000;
        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 https;
    }
}

Important: Set proxy_set_header Host $host so the Next.js app receives the original hostname. proxy.ts reads the Host header to decide whether to route as a tenant subdomain, a custom domain, or the platform.

Wildcard TLS with Certbot:

certbot certonly --dns-<your-dns-provider> \
  -d yourdomain.com \
  -d "*.yourdomain.com"

Replace --dns-<your-dns-provider> with your DNS provider's certbot plugin (e.g. --dns-cloudflare, --dns-route53). DNS challenge is required for wildcard certs.

Caddy alternative (automatic HTTPS):

*.yourdomain.com yourdomain.com {
    reverse_proxy localhost:3000
    tls {
        dns <provider> {
            # provider-specific credentials
        }
    }
}

Custom Domains (manual DNS adapter)

Without VERCEL_API_TOKEN, the app uses the manual domain adapter (src/lib/domains/manual.ts). When a merchant adds a domain in the admin:

  1. The adapter returns CNAME and TXT records for the merchant to add at their registrar:
    CNAME  shop.merchant.com     →  yourdomain.com
    TXT    _verify.shop.merchant.com  →  open-commerce-verify
    
  2. The domain status stays pending.
  3. A super-admin must log in to the admin panel and flip status to verified after confirming the DNS records are in place.
  4. Once verified, proxy.ts resolves shop.merchant.com → tenant slug and serves the store.

Your reverse proxy must forward custom-domain requests to the same Next.js process. With the nginx config above (no server_name filter), this works automatically.

No automatic DNS verification polling in the manual adapter. If you want automated verification, implement a scheduled job that calls GET /api/domains/verify?host=<hostname> and updates the row status, or super-admin verifies manually.


Media: Local Disk vs. S3

Local disk (default)

Without S3_* env vars, Payload stores uploads at ./media relative to the Next.js process. Fine for development; on production servers make sure this path is on a persistent volume (not a container's ephemeral layer).

S3-compatible object store

Set all five S3_* env vars to switch to @payloadcms/storage-s3:

VariableExample
S3_ENDPOINThttps://s3.yourdomain.com or https://<ref>.supabase.co/storage/v1/s3
S3_REGIONauto (Supabase, Cloudflare R2) or us-east-1 (AWS)
S3_BUCKETmedia
S3_ACCESS_KEY_IDYour S3 key ID
S3_SECRET_ACCESS_KEYYour S3 secret

Any S3-compatible provider works: AWS S3, Cloudflare R2, MinIO, Supabase Storage, Backblaze B2.


Environment Variable Reference

Core (required)

VariableDescription
DATABASE_URLPostgreSQL connection string. Use a transaction pooler (port 6543) if running Supabase.
PAYLOAD_SECRETRandom 32-byte hex string. Signs JWT sessions.
NEXT_PUBLIC_ROOT_DOMAINYour apex domain (e.g. yourdomain.com). Drives subdomain routing in proxy.ts.
CREDENTIALS_ENCRYPTION_KEYRandom 32-byte hex string. AES-256-GCM key for gateway secrets and HMAC tokens.

Generate random secrets:

openssl rand -hex 32

Single-store mode (optional — collapses the app to one shop)

See Single-store mode above for what this changes.

VariableDescription
NEXT_PUBLIC_SINGLE_TENANTtrue to serve one store at the domain root and hide all platform surface. Must be set before the build.
SINGLE_TENANT_ADMIN_EMAILEmail for the tenant-admin user created on first boot. Required when the flag is on.
SINGLE_TENANT_ADMIN_PASSWORDPassword for that user. Required when the flag is on.
NEXT_PUBLIC_SINGLE_TENANT_SLUGInternal store slug. Defaults to store; never appears in a URL.
SINGLE_TENANT_STORE_NAMEDisplay name for the store. Defaults to My Store.

Storage / S3 (optional — enables cloud media)

VariableDescription
S3_ENDPOINTS3-compatible endpoint URL.
S3_REGIONRegion (use auto for Supabase/R2).
S3_BUCKETBucket name.
S3_ACCESS_KEY_IDAccess key.
S3_SECRET_ACCESS_KEYSecret key.

Email (optional — enables transactional email)

VariableDescription
RESEND_API_KEYPlatform Resend API key for order confirmations and system emails.
RESEND_FROM_EMAILVerified sender address (e.g. noreply@yourdomain.com).

Cron (optional — enables marketing campaign delivery)

VariableDescription
CRON_SECRETAuth token for POST /api/marketing/process.

Custom domains / Vercel-specific (optional)

VariableDescription
VERCEL_API_TOKENVercel API token. Only needed if deploying on Vercel and want auto domain attachment. Not needed for self-hosting.
VERCEL_PROJECT_IDVercel project ID. Required when VERCEL_API_TOKEN is set.

Vercel-First Features and Self-Host Replacements

FeatureVercel behaviourSelf-host equivalent
Auto domain attachSetting VERCEL_API_TOKEN + VERCEL_PROJECT_ID registers custom domains with the Vercel project via API on creation.Use the manual adapter (no env vars needed). Super-admin marks domains verified after the merchant sets DNS records.
Cron auth injectionOn Pro/Enterprise, Vercel injects Authorization: Bearer $CRON_SECRET automatically on every cron invocation.Set up your own cron (system crontab, cron container, etc.) that hits the endpoint: curl -X POST -H "x-cron-secret: <CRON_SECRET>" https://yourdomain.com/api/marketing/process. Every-minute schedule recommended.
Wildcard TLSVercel provisions and renews wildcard certs automatically.Use Certbot with a DNS challenge plugin (--dns-cloudflare, --dns-route53, etc.) for *.yourdomain.com. Auto-renewal via certbot timer.
Serverless scalingVercel scales Next.js functions automatically.Use a Node process manager (PM2, systemd) or containerise with Docker.

Running the App

# Install dependencies
pnpm install

# Build
pnpm build

# Run migrations (once, before first start — and after each deploy with new migrations)
DATABASE_URL=<url> PAYLOAD_SECRET=<secret> pnpm payload migrate

# Start in production mode
pnpm start

With PM2:

pm2 start "pnpm start" --name ecommerce
pm2 save
pm2 startup

Docker

A Dockerfile is included. Build and run:

docker build -t ecommerce .
docker run -p 3000:3000 \
  -e DATABASE_URL=... \
  -e PAYLOAD_SECRET=... \
  -e NEXT_PUBLIC_ROOT_DOMAIN=yourdomain.com \
  -e CREDENTIALS_ENCRYPTION_KEY=... \
  ecommerce

Run migrations before starting the container the first time:

docker run --rm \
  -e DATABASE_URL=... \
  -e PAYLOAD_SECRET=... \
  ecommerce \
  pnpm payload migrate

Seed Demo Data

pnpm seed

Idempotent — safe to run multiple times. Creates:

  • Super-admin: admin@platform.local / changeme-super-1
  • Store A owner: owner@store-a.local / changeme-a-1
  • Store B owner: owner@store-b.local / changeme-b-1

Change all passwords before going live.


Troubleshooting

Custom-domain requests return the platform homepage instead of the tenant store.

  • Confirm the domain's status is verified in the Payload admin.
  • Confirm your reverse proxy passes the Host header unchanged (proxy_set_header Host $host).
  • The domain-cache TTL is 60 seconds — wait a moment after marking a domain verified.

Media uploads fail or images do not load after deploy.

  • If using S3, verify all five S3_* vars are set and the bucket is accessible.
  • If using local disk, confirm the ./media directory exists and the process has write permissions.

Marketing cron never fires.

  • Confirm CRON_SECRET is set and your cron job is running.
  • Test the endpoint manually: curl -X POST -H "x-cron-secret: <value>" http://localhost:3000/api/marketing/process.