Deploy Northstack
Put the website, the dashboard and the API online: Docker Compose on one server, one app per host from its Dockerfile, or plain Node.js, starting empty with your own Platform admin, and updating the tables after an update.
For the Full Stack package
Three ways to deploy
| Way | Good for | What you need |
|---|---|---|
| Docker Compose on one server | A VPS you control, all three apps together | The Full Stack package, Docker on the server, a reverse proxy for HTTPS |
| One app per host, from its Dockerfile | Railway, Render, Fly.io and other hosts that build from a Dockerfile | Each app's folder; a volume or MySQL, and optionally a bucket, for the API |
| Plain Node.js | Any Node host | Node.js 22.13 or a later 22 release, or 24.11 or newer, and Yarn 4 on the host |
Whichever way you choose, the website and the dashboard compile the API's address into their JavaScript when they are built, so build them with the public addresses, never the local ones.
Docker Compose on one server
Copy the package folder to the server and put a .env beside docker-compose.yml with the three public addresses. The API's allowed origins, the links it writes into invitations, password resets and checkout returns, the invoice download links, the media address and the frontends' API address are all built from them.
SITE_URL=https://www.your-domain.com
ADMIN_URL=https://app.your-domain.com
API_URL=https://api.your-domain.comThen build and start in the background:
docker compose up --build -d- Point each domain at the server through a reverse proxy that serves HTTPS (Caddy, Nginx or Traefik), forwarding to ports
3030,3031and8000. - Copy
back-end/.env.exampletoback-end/.envfor the payment, mail, storage, AI and MCP keys: the API container reads it when it exists, anddocker-compose.ymlkeeps the values that must differ inside a container. Behind the proxy, setTRUST_PROXY=1there, so the sign-in limit counts each visitor rather than the proxy. - Without a
JWT_SECRETof your own, the API generates one on its first start and keeps it in thenorthstack-datavolume. The database and the uploads live in that volume too, so back it up. - For a real install, start the new volume empty: put
SEED_SAMPLE_DATA=falsein the.envfile next todocker-compose.yml(notback-end/.env: Compose passes these three from there), withADMIN_EMAILandADMIN_PASSWORDfor your first Platform admin, before the firstdocker compose up. The.envbesidedocker-compose.ymldoes not reach the API for this. See Starting empty below. - Left at its default (
SEED_SAMPLE_DATA=true), the first start on a new volume seeds the sample workspaces and their accounts, all with the published password. Make them yours before the stack is reachable, as in Starting from the sample data below.
The API from its Dockerfile
Point the host at the API's folder (back-end/ in the Full Stack). The image runs with NODE_ENV=production, listens on PORT (8000 unless the host sets another), answers GET /api/health once its database holds the schema, and keeps SQLite and the uploads in /data.
- Mount a persistent volume at
/data. Without one, every deploy starts on a new database. - Set
SEED_SAMPLE_DATA=false, withADMIN_EMAILandADMIN_PASSWORD, so a new volume starts with your own Platform admin and no sample data. Left attrue, the default, a new volume is seeded with the sample accounts and their published password. - Set
CORS_ORIGINto the website's and the dashboard's addresses (comma separated),FRONTEND_URLandDASHBOARD_URLto the dashboard's,WEBSITE_URLto the website's,PUBLIC_API_URLto the API's public address andPUBLIC_MEDIA_URLto that address followed by/media. - On SQLite, a volume with no database is created once, as
SEED_SAMPLE_DATAsays; an existing database is never seeded again and its tables are never changed on start (see Updating below). - Set your own
JWT_SECRET, or let the container generate one and keep it in/data/.jwt-secret. - Set
TRUST_PROXY=1: a hosting platform puts a proxy in front of the container. - On MySQL (
DB_TYPE=mysqland the fiveDB_*variables), the container does not create the tables. Runnode dist/database/create-admin.jsonce with the same environment (ornode dist/database/seeder.jsfor a sample install). - Uploads on the volume survive a redeploy; to keep them off the server, set the five
R2_*variables for a bucket.
The website and the dashboard from their Dockerfiles
Point the host at the app's folder and give it the NEXT_PUBLIC_* values as build arguments, not runtime variables: they are compiled in. Each image serves Next.js's standalone server on PORT (3030 for the website, 3031 for the dashboard, unless the host sets another).
| Build argument | App | Value |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL | Both | The API's public address followed by /api. Left at its default, http://localhost:8000/api, a deployed app calls the visitor's own computer. |
NEXT_PUBLIC_SITE_URL | Website | The website's public address: canonical links, language alternates, the sitemap and the sharing cards |
NEXT_PUBLIC_DASHBOARD_URL | Website | The dashboard's public address, which Live demo, Explore the demo, See role controls, the Sign in of /error/401 and the site's /auth/* addresses open |
NEXT_PUBLIC_BASE_URL | Dashboard | The dashboard's public address: robots.txt, the sitemap and the metadata |
NEXT_PUBLIC_WEBSITE_URL | Dashboard | The website's public address, for the Documentation, Privacy and Terms links |
NEXT_PUBLIC_WEBSOCKET_BASE_URL | Dashboard | Optional. The API's public address without /api, for live permission updates; empty uses NEXT_PUBLIC_API_BASE_URL without its /api |
The website's server also reads the API while it renders the checkout and its receipt. With a public API address that works from the server too, nothing more is needed; API_INTERNAL_URL at run time overrides it when the server must use another address.
Plain Node.js
Build each app once on the host, then start it. Set the frontends' .env to the public addresses before yarn build.
| App | Build | Start |
|---|---|---|
| API | yarn build, then once on a new database yarn admin:create:prod | yarn start:prod |
| Workspace dashboard | yarn build | yarn start (port 3031, or PORT in its .env) |
| Public website | yarn build | yarn start (port 3030) |
The API binds 0.0.0.0 and reads PORT. Keep the SQLite file and MEDIA_UPLOAD_DIR on storage that survives a deploy, or use MySQL and a bucket. In production the API never creates or changes the tables by itself: yarn admin:create:prod creates them with your first Platform admin, and yarn schema:prod updates them after an update.
Starting empty, with your own Platform admin
A real install starts with no sample data and one account of yours. In the API's folder, after yarn build:
yarn admin:create:prod- It creates the tables, the platform roles and the platform settings, and the first Platform admin from
ADMIN_EMAILandADMIN_PASSWORD(8 to 128 characters) in the environment orback-end/.env. It asks in the terminal for whichever is empty, andADMIN_NAMEis optional. - It creates no workspace and no sample account, and it refuses when a Platform admin already exists. Sign in with that account and create your workspace in onboarding.
yarn admin:createdoes the same from the source, without a build.- With Docker,
SEED_SAMPLE_DATA=falsedoes this on the first start: withADMIN_EMAILandADMIN_PASSWORDset it creates the admin, and without them it creates the tables only and its log names the command to run next,docker compose exec -u node api node dist/database/create-admin.js(withdocker run,docker exec -it -u node <container> node dist/database/create-admin.js).
Never seed a public server
yarn seed:prod and yarn seed are for a sample install only: they load the sample accounts with the password published in this guide. Anyone who reads it could sign in as your Platform admin.
Updating: the tables after an update
There are no migrations, and in production the API never changes the tables by itself. After an update whose code changes the tables, back up the database, build, and bring the tables up to the new code before starting the API:
yarn build
yarn schema:prod
yarn start:prodyarn schema:prodwrites no data. It adds what is new, and it also drops a column the code no longer has, with its data, so the backup comes first.- With Docker, an existing volume is left as it is on start. Once the new image runs, apply the schema in it:
docker compose exec -u node api node dist/database/schema.js(withdocker run,docker exec -u node <container> node dist/database/schema.js).
Starting from the sample data
Every seed creates the sample workspaces and their people, the six documented sign-ins and every other seeded account all with the same published password, and the Docker image seeds a new volume by itself unless SEED_SAMPLE_DATA=false. If you start from the sample data, make the install yours before it is reachable from the internet:
- Make the seeded Platform admin yours: sign in as
platform@northstack.example, change its name and email under Settings, Profile, and its password under Settings, Security. - For your own workspace, sign up on the dashboard with your own email: onboarding creates the workspace, with you as its owner. Or make the seeded owner yours the same way and rename Forma Studio under Settings, General.
- On the platform's Users page, Delete the sample accounts you do not need, or use Change password on any you keep. An account that owns a workspace is refused until another member owns it (from that workspace's Team page) or the workspace is deleted (its Settings, Danger zone).
- Set your own
JWT_SECRET, so a sign-in token made before the switch stops working.
A seed run creates the sample accounts and workspaces only in a database that has no account yet. Once accounts exist, running yarn seed or yarn seed:prod again adds no account or workspace and only gives the built-in platform roles their permissions back, so a sample account you renamed or deleted never comes back with the published password.
Going live
Before Northstack is reachable from the internet:
- In the API's environment,
NODE_ENV=productionand a long randomJWT_SECRETof your own (Docker generates one when none is set). In production the API refuses to start on the example secret or withoutCORS_ORIGIN, and says which value is wrong. CORS_ORIGINat the website and the dashboard,FRONTEND_URLandDASHBOARD_URLat the dashboard,WEBSITE_URLat the website,PUBLIC_API_URLat the API andPUBLIC_MEDIA_URLat the API followed by/media.- Your live Stripe key and its webhook at
/api/billing/webhooks/stripeon the API's address, withSTRIPE_WEBHOOK_SECRET: without it the API says so in its log at start and refuses every delivery, so renewals and failed payments are never recorded. For PayPal, the live app's keys withPAYPAL_ENV=live. - Tax set up for your country: Northstack charges none by itself.
- The
SMTP_*settings, or nobody receives invitations, password resets or licence receipts. - The database and the uploads on persistent storage, with a backup.
- The host's health check at
/api/health, andTRUST_PROXY=1behind a proxy. - Both frontends built with the public
NEXT_PUBLIC_*values. - Your brand in each frontend, and the website's legal pages rewritten for your business and your country.
- No sample accounts: started empty with
yarn admin:create:prodorSEED_SAMPLE_DATA=false, or the seeded ones removed or their passwords changed, as above. DEMO_MODE=falseon the API andNEXT_PUBLIC_DEMO_MODE=falsein both frontends, as shipped.