OPERATIONS

Deployment

Deployment

Asset service schema

The asset service builds its own schema on start-up. A new deployment needs credentials and nothing else:

DB_HOST=...   DB_PORT=3306   DB_USER=...   DB_PASSWORD=...   DB_NAME=my_store_assets

On boot it creates the database if missing, then runs any pending migrations from src/migrations. Booting against an empty MySQL server produces a working service; booting against a database whose tables already exist adopts them without touching the rows.

Two escape hatches, both optional:

  • DB_AUTO_CREATE=false — skip the create step, for a user scoped to one schema with no CREATE privilege. Create the database by hand once.
  • MIGRATIONS_RUN=false — do not migrate on boot; run npm run migration:run as a separate deploy step instead. Prefer this when several instances start at once.

Adding a column later: change the entity, run npm run migration:generate, commit the generated file and import it in database.module.ts. It applies on the next deploy.

The application code does not change between local and production. Four pieces of infrastructure do, plus the environment variables below.

1. Wildcard DNS

Every store is its own hostname, so a single record has to cover them all:

A     my-store.shop        -> <server ip>
A     *.my-store.shop      -> <server ip>

The wildcard is what makes a store reachable the moment it is created — no DNS change per signup.

2. Wildcard TLS certificate

Each store is a distinct hostname, so one certificate must cover them:

my-store.shop
*.my-store.shop

Let's Encrypt issues wildcards only over the DNS-01 challenge, which needs an API token for your DNS provider. HTTP-01 will not work here.

Note that *.my-store.shop covers jet-store.my-store.shop but not a.b.my-store.shop — one label only, which matches how the app resolves stores.

3. Reverse proxy routing

Three apps behind one apex:

Hostname Serves
my-store.shop storefront — landing page where stores are created
*.my-store.shop storefront — the store named by the subdomain
admin.my-store.shop admin console
api.my-store.shop API

admin, api, www, app, static and assets are reserved in both middlewares and are never treated as store names, so hosting the console at admin.my-store.shop is safe.

The storefront and the store subdomains are the same app — its middleware reads the host and rewrites internally to /s/<slug>. Only one storefront deployment is needed.

4. Environment

The domain is configuration, not code. These are the values that switch a deployment from local to my-store.shop.

# apps/api
ROOT_DOMAIN=my-store.shop
CORS_ORIGINS=https://my-store.shop,https://admin.my-store.shop

DB_HOST=...              DB_USER=...            DB_PASSWORD=...
DB_CONTROL_NAME=my_store_control
DB_SYNCHRONIZE=false     # never true in production
JWT_SECRET=<64 random characters>
GOOGLE_CLIENT_ID=<from the Google Cloud console>

# apps/web
NEXT_PUBLIC_ROOT_DOMAIN=my-store.shop
NEXT_PUBLIC_STORE_ROUTING=subdomain
NEXT_PUBLIC_API_URL=https://api.my-store.shop
NEXT_PUBLIC_ADMIN_URL=https://admin.my-store.shop
NEXT_PUBLIC_GOOGLE_CLIENT_ID=<same client id>

# apps/admin
NEXT_PUBLIC_ROOT_DOMAIN=my-store.shop
NEXT_PUBLIC_STORE_ROUTING=subdomain
NEXT_PUBLIC_API_URL=https://api.my-store.shop
NEXT_PUBLIC_STOREFRONT_URL=https://my-store.shop
NEXT_PUBLIC_GOOGLE_CLIENT_ID=<same client id>

# apps/asset-service
AWS_REGION=...  S3_BUCKET=...  S3_PUBLIC_HOST=<cloudfront domain>
AWS_ACCESS_KEY_ID=...  AWS_SECRET_ACCESS_KEY=...
CORS_ORIGINS=https://my-store.shop,https://admin.my-store.shop

NEXT_PUBLIC_STORE_ROUTING decides how a store is addressed: subdomain gives jet-store.my-store.shop, path gives my-store.shop/s/jet-store. Left unset it follows the root domain — localhost gets paths, a real domain gets subdomains — so a deployment that sets only NEXT_PUBLIC_ROOT_DOMAIN still behaves correctly. Keep it on path locally: Google rejects *.localhost origins, so sign-in cannot work on jet-store.localhost:3000.

CORS_ORIGINS on the API is additive, not a replacement. The apex and any single-label subdomain of ROOT_DOMAIN are admitted whatever it says, so a new store works the moment it is created; the list is for origins outside that apex.

NEXT_PUBLIC_* values are inlined at build time, so changing one means rebuilding that app, not just restarting it.

CORS on the API needs no list of stores: it admits the apex and any single-label subdomain of ROOT_DOMAIN, so a new store works immediately. The asset service still takes an explicit CORS_ORIGINS list, since uploads only ever come from the console.

Dry run before you own the domain

Production hostnames can be exercised locally with hosts entries:

127.0.0.1  my-store.shop jet-store.my-store.shop admin.my-store.shop api.my-store.shop

Run each app with its production environment and a real browser will hit your local servers using the real hostnames. Everything except TLS behaves as it will in production.

What has been verified, and what has not

Tested by setting ROOT_DOMAIN and sending Host / Origin headers — the server reads those headers and never resolves DNS, so the results hold for the real domain:

  • subdomain to store resolution
  • admin reserved, never read as a store
  • /ms-admin redirecting to https://admin.my-store.shop/?store=<slug>
  • CORS admitting *.my-store.shop and rejecting my-store.shop.evil.com
  • store-bound JWTs — editing ?store= yields 401

Not tested, and not testable without the domain: wildcard DNS, the TLS certificate, reverse-proxy routing, and real browser resolution.

Product colours

Swatches are editable in the product drawer. Each is a hex value with two ways in — a colour picker for choosing by eye, and a text field for pasting a code from a brand sheet. Both write the same value, and an invalid code is marked before saving; the API refuses one that is not hex, since these reach a style attribute on the storefront.

The label under the swatches is derived from the count unless a store writes its own, so a product can never claim three colours while showing two. A written label — "Black / Ecru" — is kept, and a label that reads like a count is recounted when the colours change, because it was ours to begin with.

Before this, colors could only be seeded: a product created in the console had none, while colorLabel was editable and free to disagree with them.

The cart belongs to the account

Adding to the cart requires a signed-in shopper. Someone who is not signed in gets the sign-in dialog, and the item they were adding is remembered — it lands in the cart the moment they sign in, rather than making them find the product again.

Lines live in cart_items, unique on (customer, product, size), so adding the same size twice raises the quantity instead of making a second line. The storefront writes through on every change: add, quantity, removal, and empty on checkout, since the order owns those lines once it is placed.

The cart follows the account rather than the browser: sign in on another device and it is there. A signed-out cart — built before this gate existed, or on a page that still allows it — is merged in at sign-in rather than dropped.

A line whose product is no longer live is dropped when the cart is read. A cart that offers something unbuyable wastes the shopper's time at checkout instead of here.

node scripts/add-cart-table.js adds the table to existing stores.

The storefront says Cart throughout — the header, the drawer, the mobile tab and the page. Product cards say ADD TO CART rather than "Quick add", which describes what the button does rather than how fast it is.

Dynamic pages

A store can author its own pages. Each is a row in its pages table with an ordered list of blocks as JSON, served by a catch-all storefront route and rendered by a component per block type.

GET  /pages              published pages (slug + title), for navigation
GET  /pages/:slug        one published page with its blocks
GET  /admin/pages        every page, drafts included
POST /admin/pages        create · PUT /admin/pages/:id · DELETE

Block types today: heading, richText, image, productGrid, cta, spacer. productGrid reads the live catalogue on the server, so an authored page shows real products rather than a snapshot.

The contract has two halves that must agree: BLOCK_TYPES in apps/api/src/pages/blocks.ts is what the API will store, and the switch in apps/web/src/components/blocks/index.tsx is what the storefront can draw. The API refuses a type the renderer does not know; the renderer skips a type it does not recognise rather than throwing, so one bad block cannot take a page down.

Things that are enforced rather than assumed:

  • Reserved slugs. /account, /bag, /product and the rest are refused at save. Next matches its own file routes first, so such a page would exist, be published, and never appear.
  • Drafts are invisible. A draft reads as 404 on the storefront, including on a direct link — whether an unpublished page exists is not something a visitor should be able to discover.
  • One segment. /about is a page; /about/team is a 404 until nested pages are designed properly.
  • Per store. Pages live in the store's own database, so one shop's page is not reachable from another.

What a new store starts with

Five pages, seeded at provisioning:

Page Address Kind Contents
Home / blocks the nine storefront sections
Shop /shop blocks a heading and a 12-product grid
About Us /about-us blocks heading, placeholder copy, a call to action
New Arrivals /new-arrivals system the listing, with filters
Cart /bag system the basket

System pages stand for routes that are code, not blocks. A listing with filters and stock, and a cart with a basket, are not things a block editor should own — but a store should still see them in one list and be able to put them in a menu without knowing which parts of its shop are code.

They can be renamed, which changes how they read in menus, and unpublished to keep them out of the page picker. They cannot be deleted or given blocks, and their address belongs to the route: Cart is a row whose address is /bag, which is why a system page carries a route separate from its slug.

GET /pages returns each page's href, so a menu links to the right place without having to know the difference.

The About page carries written placeholder text that says it is placeholder. A shop that opens with a blank About page has something to edit; one with no About page at all has something to discover.

The starter menu points at them — Home, Shop, New Arrivals, About Us, Cart — so the header works from the first minute rather than linking to pages that do not exist. Cart is a menu entry, not a page: /bag is an interactive route with its own logic, not something built from blocks.

node scripts/seed-default-pages.js gives existing stores any of these they are missing, and never overwrites a page that already exists.

Menus

Menus are records, not part of a theme. The console has a Menus section where a store builds named lists of links — a main menu, a seasonal one, a short one for a campaign — and a theme picks which one its header shows (Themes → Header).

That separation is the point: a menu can be shared by several themes, and switching themes no longer switches the navigation with it.

GET/POST      /admin/menus
PUT/DELETE    /admin/menus/:id
  • Items carry a label, a path, an accent highlight, and hidden. A hidden item stays in the menu but never reaches the header, so a seasonal link is switched off rather than rebuilt.
  • Add a link lists the store's published pages and the built-in routes (New Arrivals, Discover, Cart, Wishlist, Account), so linking to a page you wrote does not mean typing its address from memory.
  • A menu in use cannot be deleted — the API names the themes pointing at it rather than leaving one with an empty header.
  • The storefront never sees the indirection. GET /theme resolves the selected menu and returns the links; a theme that selects none, or points at a deleted menu, falls back to its own inline list.

node scripts/add-menus-table.js creates the table for existing stores, moves each one's current header links into a "Main menu", and points every theme at it — so nothing about the rendered header changes.

The home page is a page

The storefront's front page is a row in pages with the slug home, seeded with the exact sequence the route used to hardcode: mobile greeting, hero, marquee, mobile chips, product rails, category grid, featured collection, customer photos, newsletter. Rendering it from the database produces output identical to the old fixed layout — verified by diffing the visible text of both renders, 213 strings each.

That makes the front page editable: reorder the sections, drop the ones a store does not want, or add authored blocks between them.

Nine block types cover those sections. They take no configuration — they read from the theme, the catalogue and the feed — so what they offer the editor is a position in the order.

Rules around it:

  • It cannot be deleted or unpublished, and its address is fixed. A store without a front page has nothing to serve at its root.
  • It answers only at /. The catch-all refuses /home, so the page never has two addresses.
  • Stores with no home page still work. The route falls back to the built-in sequence, so a store provisioned before pages existed renders exactly as before — it simply cannot rearrange until node scripts/seed-home-page.js gives it the row.

New stores are seeded with it at provisioning.

The console has a Pages section: a list with status and a link to the live page, NEW PAGE to create one, and an editor that adds, reorders and removes blocks with a form per type. Pages are created as drafts, so a new page is never live before it is written.

Block edits are local until SAVE PAGE — reordering three blocks is one write, not three. The exception is an image, which uploads on pick like every other image in the console.

node scripts/add-pages-table.js adds the table to existing stores.

Rich text is rendered as authored, because it comes from the store's own admin. If it ever accepts input from anyone but staff, it must be sanitised first.

Store logo and favicon

Settings carries a logo and a favicon alongside the store's name, each stored the same way product images are: the asset id and the url the asset service returned, never a bucket path. Uploads are filed under a branding folder.

  • Logo replaces the wordmark in the storefront header. Without one the header renders the store name as text, which is what the themes are drawn around — so a store that never uploads anything still looks deliberate.
  • Favicon becomes the tab icon via the page's icons metadata. Without one the platform's own favicon is served.

The logo's size and shape are configurable: a height in pixels (20–120, default 44) and a rounded toggle that crops it to a circle. Rounded suits a mark or a monogram; a wide wordmark usually wants it off, so it is a choice rather than a rule. The settings screen previews both against the header's own dark ground, so the result is visible before saving.

Height is clamped server-side as well as validated — a stray value would break the header on every page of the storefront.

Both images save the moment a file is picked rather than waiting for SAVE SETTINGS: the upload has already happened by then, and leaving the row unsaved would put an object in the bucket that nothing points at.

Clearing is explicit. The API distinguishes a field that was not sent from one sent as null, so renaming a store leaves its logo alone while the console's clear button actually removes it.

Columns are added to existing stores by node scripts/add-branding-columns.js; new stores get them from provisioning.

Plans and the store allowance

An account created on the landing page is on the free plan, which allows 3 stores. Creating a fourth is refused with 402 Payment Required and a machine-readable code: "store_limit_reached", alongside the plan, the limit and how many are used.

GET /platform/usage
{ "plan": "free", "limit": 3, "used": 2, "remaining": 1, "canCreate": true }

The create-store form reads that and shows "2 of 3 stores used" before anyone types a name; at the limit the button reads STORE LIMIT REACHED and explains that subscribing lifts it. The 402's message is surfaced too, so a stale reading of the allowance still cannot get a fourth store through.

Details worth knowing:

  • Only stores an account owns count. Being invited to someone else's shop as staff does not spend your allowance.
  • The check runs before provisioning, which creates a database — refusing afterwards would leave one behind. Verified: a refused create leaves no database and no registry row.
  • Retrying a store the account already owns is never blocked by the allowance it has already spent.
  • accounts.store_limit overrides the plan for a single account, for a manual grant or an internal account. Null means use the plan's number.
  • Allowances live in PLAN_STORE_LIMITS (apps/api/src/tenancy/control/account.entity.ts). Adding a paid tier is a line there plus whatever sets accounts.plan; nothing else needs to change.

Billing itself is not built — no payment provider, no upgrade flow. Every account is free until something sets plan or store_limit directly.

Deleting a store

An allowance you cannot get back is a trap: three failed experiments and a free account is finished, with nothing to show for it. Deleting a store gives the slot back.

DELETE /platform/stores/<slug>?confirm=<slug>
{ "deleted": true, "slug": "acme", "database": "deleted",
  "domain": "removed", "usage": { "used": 1, "limit": 3, ... } }

It removes everything the platform made for that store: the parked domain, the database and everything in it, the membership rows and the registry row. There is no backup and no undo.

  • Owner only. Staff invited to help run a shop cannot take it away from the person whose allowance it is; they get a 403.
  • ?confirm=<slug> must match, or the request is a 400 that deletes nothing. The console asks the merchant to type the store's name, and the server asks for the same thing — so a DELETE that arrives by accident, from a stray request or a mis-wired button, is inert.
  • Order matters. The database goes before the rows, because it is the part that can genuinely fail. The reverse would leave a database with no row naming it, which nothing would ever clean up.
  • "database": "absent" is still success — it means there was nothing left to remove. A store that failed to provision often has no database at all, and those are exactly the ones people need to delete.
  • The parked domain is best-effort, as it is when creating one. "domain": "failed" means the store is gone but its alias outlived it; the console says so, because a leftover alias will get in the way of reusing the name.

On Hostinger, deleting a database does not delete the MySQL user created alongside it — the API exposes no way to do that. So database users are named with a random suffix rather than from the slug alone; a name built only from the slug would collide the first time somebody recreated a store they had deleted, and Hostinger answers that with a bare 422.

Verified end to end against MySQL: three stores created to the limit, a fourth refused with 402, one deleted, the fourth then accepted — and a store recreated under the name of a deleted one provisions cleanly.

Reserved subdomains

In production a store is its hostname, so a shop slugged admin would claim the console's own address — and the middleware, which refuses to read these as stores, would route it to the admin instead. The shop would exist in the database and be permanently unreachable.

RESERVED_SUBDOMAINS in apps/api/src/tenancy/control/store.entity.ts is the source of truth: 39 names covering the surfaces routed elsewhere today (admin, api, assets), hostnames mail and infrastructure providers expect (mx, smtp, ns1, autodiscover), the fixed sign-in origin OAuth providers need (auth, login, accounts), and platform surfaces worth keeping free (billing, docs, status).

Creating a store with one of these names is refused with a clear message. The web middleware repeats the list because it runs in a separate app; npm run check:reserved fails if the two drift.

Nothing about local routing changes: ?store=, X-Store-Slug and <slug>.localhost all behave exactly as before.

The account area is real

Every panel under /account reads the signed-in shopper's own rows, selected by the customer id inside the token:

Panel Source
Profile stats orders placed, saved items, lifetime spend — counted
Member since / tier the customer row
Preferences preferences, one set per shopper; store rows with no owner are the defaults each shopper copies on first read
Orders orders, formatted by the orders service
Wishlist wishlist_items, unique per (customer, product)
Addresses / Payment addresses, payment_cards, scoped to the shopper
Recently viewed this browser's own history, per store

The wishlist follows the account rather than the browser: saves made while signed out are merged in at sign-in, and the list is re-read on every session restore. Renaming yourself writes to customers.

npm run seed no longer plants demo addresses or cards into a store that has customers, so a real shopper never inherits someone else's saved card.

Partial saves must not erase uploads

A validated DTO is a class instance, and TypeScript materialises every declared field on it. A theme save carrying only hero: { title } therefore arrives with assetId: undefined and url: undefined as real properties. Spreading that over the stored hero replaced the uploaded banner with undefined — which JSON.stringify then drops, so the image disappeared on any save that did not resend it.

ThemeService.update merges through defined(), which keeps only the keys the client actually sent. Sending assetId: null still clears the image deliberately; omitting it keeps what is there.

Anywhere a DTO is merged onto stored data, filter undefined first.

npm run check:images reports missing heroes, products with no image, and products whose slots are only partly filled. Add --fetch to check every URL still resolves.

The seed does not delete

npm run seed used to TRUNCATE sixteen tables, including product_images, themes and store_settings — so refreshing the demo catalogue threw away uploaded photography, hero banners, the store's own name, and live customers and orders.

It now adds and updates without clearing anything:

  • products are matched by SKU, categories by slug, filter groups by title, so ids survive and uploaded images stay attached to their product
  • themes, settings, customers, orders and account rows are only planted into a store that has none of them
  • npm run seed -- --reset is the old behaviour, and says so before it runs

Deploying

deploy/ holds what a server needs:

File What it is
build.sh installs, builds all four apps, and copies browser assets into the standalone output
ecosystem.config.js PM2 process list — storefront 3000, API 3001, admin 3002, assets 3003
nginx.conf reverse proxy for the apex, *.domain, admin, api and assets
HOSTINGER.md step by step on a Hostinger VPS, including wildcard DNS and TLS

The Next apps build with output: "standalone", so the server ships without the repository or a full node_modules. Standalone deliberately leaves out .next/static and public/ — it assumes a CDN serves them — so build.sh copies them in. Skipping that is why a deployed Next site comes up with no styling.

Verified by running the standalone server exactly as a VPS would: landing page, storefront, docs and a subdomain host all 200, with CSS and JS served.

JSON columns and synchronize

MariaDB implements JSON as an alias for LONGTEXT. TypeORM compares its own json type against what the server reports, sees a difference that cannot be reconciled, and drops and re-adds the column on every synchronize — silently emptying it. Nullable columns come back NULL; NOT NULL ones come back empty.

That is why theme heroes, footers, colours and menus disappeared on restart.

Two things keep it fixed, and both matter:

  • Entities declare simple-json, not json. It stores the same value in a TEXT column that both sides agree on.
  • DB_SYNCHRONIZE=false. Schema changes go through POST /stores/:slug/sync, run deliberately, rather than on every boot.

For a database created before this, node scripts/align-json-columns.js converts the columns in place with ALTER ... MODIFY, which keeps the data. Run it with no arguments to do every store in the registry.

Never add @Column({ type: "json" }) to an entity here.