Running it
Deployment
Running a site with Polaris CMS in production: PostgreSQL, environment variables, migrations on start, the Docker image, and running behind a reverse proxy.
A Polaris site is your site plus a database. In production that means one container (or one Node process) and one PostgreSQL database. This page follows the newest Astro version, as deployed on scentro.pl with Coolify; the kit versions differ where noted.
What runs where#
In the newest Astro version, one container holds two processes:
- nginx on port 80 serves every prerendered static page straight from disk (precompressed, cached) and proxies the dynamic paths to Node;
- Node (Astro's
@astrojs/nodeadapter, standalone, on127.0.0.1:4321) renders the blog, the admin, the API, uploaded media, the sitemap and the IndexNow key file.
Proxied paths: /blog* (and /pl/blog*), /studio*, /api/* (uploads up to 12 MB), /media/*, /sitemap.xml and /<INDEXNOW_KEY>.txt. If either process exits, the container exits, so the platform restarts it.
The Next.js kit is a single node server.js process on port 3000 (output: "standalone"), with a /api/health route for the health check.
PostgreSQL#
PostgreSQL 16 or newer, any host with a connection string: a database in the same Docker network, a managed service, a local install. Posts, images, backups and API keys all live in it, so the app container needs no volume.
- On a private Docker network, append
?sslmode=disableto the URL. Otherwise TLS is required (localhost is exempt). - Don't expose the database publicly.
- Turn on the platform's regular database backups. Polaris keeps its own post snapshots, but in the same database.
Environment variables#
All runtime variables, read on every start.
| Name | Required | What |
|---|---|---|
DATABASE_URL |
yes, for the blog and the admin | PostgreSQL URL. ?sslmode=disable on an internal network |
ADMIN_USERNAME |
yes, for the admin | The admin login name |
ADMIN_PASSWORD |
yes, for the admin | A long random password. Changing it signs every session out |
STUDIO_SESSION_SECRET |
recommended | 32+ random characters mixed into the session token. Rotate it to sign everyone out |
INDEXNOW_KEY |
optional | 8 to 128 characters [A-Za-z0-9-]. Enables IndexNow pings on publish and serves /<key>.txt |
CRON_SECRET |
optional | Enables GET /api/cron/daily with Authorization: Bearer <secret> |
GOOGLE_SERVICE_ACCOUNT_JSON |
optional | Service-account key (JSON, one line) for the admin's Search Console checker |
GOOGLE_SEARCH_CONSOLE_SITE_URL |
optional | The Search Console property, default <site URL>/; e.g. sc-domain:example.com |
DATABASE_POOL_MAX |
optional | Connection pool size, default 10. Use 1 with PGlite |
HOST and PORT for Node are pinned inside the image (127.0.0.1:4321, nginx owns port 80), so a platform's own PORT variable is ignored. NODE_ENV=production (set in the image) makes the session cookie Secure.
Without DATABASE_URL the static pages still work: the blog shows an empty state and the admin says the database is missing.
Kit differences. The session secret is SESSION_SECRET in the Next.js kit; the Astro kit has none (the token derives from the password alone). The kits also read the site URL from the environment (PUBLIC_SITE_URL in Astro, NEXT_PUBLIC_SITE_URL in Next.js, both needed at build time) and take Cloudflare Turnstile keys for comments. DATABASE_PREPARE=false turns off named prepared statements, for PGlite or PgBouncer in transaction mode.
Migrations#
Migrations are plain SQL files run in name order, once each, each in its own transaction, recorded in a cms_migrations table. The container runs them on every start, before the server:
migrated 0001_blog_cms.sql ← first start
database up to date ← every start after that
The script retries for about a minute while PostgreSQL boots, then starts the app anyway. To change the schema, add a new file (migrations/0002_<name>.sql) and update the Drizzle schema to match; never edit an applied migration. Migrations only add, so the previous image keeps working against a migrated database: rolling back is redeploying the old image.
The Next.js kit keeps its SQL in drizzle/ and runs scripts/migrate.mjs on start; set RUN_MIGRATIONS=0 to skip that and run them as a separate step.
Docker#
The image is built in three stages:
- build (Node 24 on Alpine):
npm ci,npm run build. The build fails if any static page has an inline script or style, then writes.brand.gznext to every text asset. - deps:
npm ci --omit=dev, the production modules the server needs (Drizzle, postgres.js, sharp, React). - run (Node 24 on Alpine + nginx with the brotli module): the built site, the production modules, the migrations and the nginx config. The entrypoint runs the migrations, starts Node and nginx, and exits if either dies.
The image's HEALTHCHECK checks nginx and the Node app; point the platform's health check at GET /healthz, which nginx answers with 200 ok.
Behind a reverse proxy#
Polaris expects to sit behind a proxy that terminates TLS (Coolify's Traefik, Caddy, a load balancer, Cloudflare in front of any of them):
- The container listens on plain HTTP, port 80. nginx listens on IPv4 and IPv6, so a health check that resolves
localhostto::1still works. - The login rate limit uses the first address in
X-Forwarded-For. Make sure your proxy sets it, or every visitor shares one limit. - Redirects are relative, so they keep the public scheme and host.
- With Cloudflare in front, use SSL mode Full (strict). Cache
/_astro/*as long as you like; never add a "cache everything" rule for/studioor/api.
Coolify, step by step#
-
In the project, add a PostgreSQL resource (16 or 17) on the same server and network as the app. Turn on its scheduled backups. Copy its internal URL and append
?sslmode=disable: that isDATABASE_URL. -
Add the application from its Git repository with the Dockerfile build pack, port 80, health check path
/healthz. -
Set the runtime variables:
DATABASE_URL,ADMIN_USERNAME,ADMIN_PASSWORD,STUDIO_SESSION_SECRET, and any optional ones. -
Add the domain, deploy, and watch the log for
migrated 0001_blog_cms.sql. -
Check it:
curl -s https://example.com/healthz # ok curl -s https://example.com/blog/rss.xml | head -5 curl -s -X POST https://example.com/api/cms/posts \ -H "Content-Type: application/json" -d '{"action":"list"}' # 401 without a key -
Sign in at
/studioand create an API key for your agents.
A Coolify scheduled task is not needed with the newest version. With a kit, add one that calls /api/cron/daily once a day.
Local development#
The build machine doesn't need Docker or PostgreSQL. A throwaway database comes from PGlite (PostgreSQL compiled to WebAssembly, a dev dependency) behind a wire-protocol socket:
node scripts/dev-db.mjs 5599 memory
export DATABASE_URL="postgres://postgres@127.0.0.1:5599/postgres?sslmode=disable" DATABASE_PREPARE=false DATABASE_POOL_MAX=1
node scripts/db-migrate.mjs
ADMIN_USERNAME=dev ADMIN_PASSWORD=dev-password npm run devOpen source under MIT. Public release on GitHub and npm in preparation. Roadmap.