Skip to the article
Aniq-UI

NorthstackDeploy Northstack

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

WayGood forWhat you need
Docker Compose on one serverA VPS you control, all three apps togetherThe Full Stack package, Docker on the server, a reverse proxy for HTTPS
One app per host, from its DockerfileRailway, Render, Fly.io and other hosts that build from a DockerfileEach app's folder; a volume or MySQL, and optionally a bucket, for the API
Plain Node.jsAny Node hostNode.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.

.env beside docker-compose.yml
SITE_URL=https://www.your-domain.com
ADMIN_URL=https://app.your-domain.com
API_URL=https://api.your-domain.com

Then build and start in the background:

Terminal
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, 3031 and 8000.
  • Copy back-end/.env.example to back-end/.env for the payment, mail, storage, AI and MCP keys: the API container reads it when it exists, and docker-compose.yml keeps the values that must differ inside a container. Behind the proxy, set TRUST_PROXY=1 there, so the sign-in limit counts each visitor rather than the proxy.
  • Without a JWT_SECRET of your own, the API generates one on its first start and keeps it in the northstack-data volume. 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=false in the .env file next to docker-compose.yml (not back-end/.env: Compose passes these three from there), with ADMIN_EMAIL and ADMIN_PASSWORD for your first Platform admin, before the first docker compose up. The .env beside docker-compose.yml does 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, with ADMIN_EMAIL and ADMIN_PASSWORD, so a new volume starts with your own Platform admin and no sample data. Left at true, the default, a new volume is seeded with the sample accounts and their published password.
  • Set CORS_ORIGIN to the website's and the dashboard's addresses (comma separated), FRONTEND_URL and DASHBOARD_URL to the dashboard's, WEBSITE_URL to the website's, PUBLIC_API_URL to the API's public address and PUBLIC_MEDIA_URL to that address followed by /media.
  • On SQLite, a volume with no database is created once, as SEED_SAMPLE_DATA says; 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=mysql and the five DB_* variables), the container does not create the tables. Run node dist/database/create-admin.js once with the same environment (or node dist/database/seeder.js for 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 argumentAppValue
NEXT_PUBLIC_API_BASE_URLBothThe 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_URLWebsiteThe website's public address: canonical links, language alternates, the sitemap and the sharing cards
NEXT_PUBLIC_DASHBOARD_URLWebsiteThe 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_URLDashboardThe dashboard's public address: robots.txt, the sitemap and the metadata
NEXT_PUBLIC_WEBSITE_URLDashboardThe website's public address, for the Documentation, Privacy and Terms links
NEXT_PUBLIC_WEBSOCKET_BASE_URLDashboardOptional. 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.

AppBuildStart
APIyarn build, then once on a new database yarn admin:create:prodyarn start:prod
Workspace dashboardyarn buildyarn start (port 3031, or PORT in its .env)
Public websiteyarn buildyarn 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:

Terminal
yarn admin:create:prod
  • It creates the tables, the platform roles and the platform settings, and the first Platform admin from ADMIN_EMAIL and ADMIN_PASSWORD (8 to 128 characters) in the environment or back-end/.env. It asks in the terminal for whichever is empty, and ADMIN_NAME is 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:create does the same from the source, without a build.
  • With Docker, SEED_SAMPLE_DATA=false does this on the first start: with ADMIN_EMAIL and ADMIN_PASSWORD set 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 (with docker 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:

Terminal
yarn build
yarn schema:prod
yarn start:prod
  • yarn schema:prod writes 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 (with docker 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:

  1. 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.
  2. 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.
  3. 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).
  4. 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:

  1. In the API's environment, NODE_ENV=production and a long random JWT_SECRET of your own (Docker generates one when none is set). In production the API refuses to start on the example secret or without CORS_ORIGIN, and says which value is wrong.
  2. CORS_ORIGIN at the website and the dashboard, FRONTEND_URL and DASHBOARD_URL at the dashboard, WEBSITE_URL at the website, PUBLIC_API_URL at the API and PUBLIC_MEDIA_URL at the API followed by /media.
  3. Your live Stripe key and its webhook at /api/billing/webhooks/stripe on the API's address, with STRIPE_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 with PAYPAL_ENV=live.
  4. Tax set up for your country: Northstack charges none by itself.
  5. The SMTP_* settings, or nobody receives invitations, password resets or licence receipts.
  6. The database and the uploads on persistent storage, with a backup.
  7. The host's health check at /api/health, and TRUST_PROXY=1 behind a proxy.
  8. Both frontends built with the public NEXT_PUBLIC_* values.
  9. Your brand in each frontend, and the website's legal pages rewritten for your business and your country.
  10. No sample accounts: started empty with yarn admin:create:prod or SEED_SAMPLE_DATA=false, or the seeded ones removed or their passwords changed, as above.
  11. DEMO_MODE=false on the API and NEXT_PUBLIC_DEMO_MODE=false in both frontends, as shipped.

Stuck on a step?

Find a fix before you start over.

Troubleshooting

Cookie Preferences

We use cookies to enhance your browsing experience, analyze site traffic, and personalize content. By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising.