Installing phis
This walkthrough takes a fresh Linux host to a running
@phis/server with one Site, in order. Every
command is described in
CLI.md; the configuration keys are listed in
README.md.
The examples use:
Run phis with --root /srv/phis/stages/prod, or export PHIS_ROOT=/srv/phis/stages/prod once; the
examples below assume the variable is exported.
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
1. Prerequisites
Node.js 24.21.0 or later, for the server, the CLI and every Site. Earlier releases leak memory
under React Flight rendering and run out of memory under load.
@phis/server declares
"engines": {"node": ">=24.21.0"}, a generated Site the same, the Debian package depends on
nodejs (>= 24.21.0), and the server's setup page fails its Node check below that. Neither Debian 13
(trixie) nor Ubuntu 24.04 (noble) ships Node 24 in its own archive; install it from
NodeSource first, whose
nodejs package satisfies that
dependency. Without it
apt install phis stops on the unmet dependency, because noble's own
nodejs
is 18; a host whose NodeSource Node is older than 24.21 gets it upgraded along with the package, and
services already running on the old Node keep it until they restart.
No package manager for the server itself when it is installed from the Debian package or the
package tarball: the server and the phis CLI run on Node alone. pnpm is needed only to build the
server from source (the release builds with pnpm 11; @phis/server names pnpm in devEngines, so npm
refuses to run the repository's own scripts).
A package manager for each Site, which
phis init and
phis update run in the Site's directory:
npm by default for a new Site, or yarn, bun or pnpm if the Site uses one; see
Package manager.
PostgreSQL with the pg_trgm extension available (Debian: part of the postgresql
package). pg_trgm is a trusted extension, so a role with CREATE on the database can install it.
A reverse proxy (nginx or Apache) for TLS and public host names.
A system account the services run as, e.g. phis, owning the phis root, the Local media root and
the Site instance directories.
Outbound SMTP relay reachable from the server (email.smtp.targets), if the Sites send mail.
Create the two database roles and the database. The serving role owns the Core schema phis; the
migration role owns the schemas of server Add-ons. Both need CREATE on the database.
phis db bootstrap does this, once the server is installed — step 2 below, after which:
phis db bootstrap --admin-uri postgres://<admin>@<host>:5432/postgres --name prod
It creates
phis_prod,
phis_prod_user and
phis_prod_migrator, generates a password for each, and writes
both connection strings into
config/phis-runtime.json, so step 2's table does not ask for them again
(
CLI.md).
--admin-uri is a connection allowed to
create databases and roles, and it must name a host; it need not be a superuser.
By hand, equivalently:
CREATE ROLE phis_prod_user LOGIN PASSWORD '<serving password>';
CREATE ROLE phis_prod_migrator LOGIN PASSWORD '<migration password>';
CREATE DATABASE phis_prod OWNER phis_prod_user;
GRANT CREATE ON DATABASE phis_prod TO phis_prod_migrator;
From source
The repository builds as a package of its own, the way the release workflow builds it
(.github/workflows/release.yml). It carries no lockfile and no pnpm-workspace.yaml; its one sibling
dependency, @phis/contracts, comes from npm at the version package.json names. It needs Node 24.21 or
later and pnpm 11. Write the workspace file the release writes -- it allows the native build scripts and
exempts the @phis packages from pnpm's minimum release age -- then install, build the server and
bundle the CLI:
git clone https://github.com/phis-dev/phis-server.git && cd phis-server
printf 'allowBuilds:\n argon2: true\n esbuild: true\n sharp: true\n unrs-resolver: true\nminimumReleaseAgeExclude:\n - "@phis/*"\n' > pnpm-workspace.yaml
pnpm install --no-frozen-lockfile
pnpm build # next build
pnpm build:cli # dist/phis.mjs, which ./phis prefers
Run the CLI as
pnpm phis ... or
./phis ... from the
phis-server directory
(
CLI.md).
From the Debian package
The package is published to the apt repository on apt.phisys.com for Debian 13 (trixie) and Ubuntu
24.04 (noble), on amd64 and arm64. Every release lands in the testing channel; stable receives a
release once it is promoted. Add the repository and its signing key, then install:
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://apt.phisys.com/repo-signing-key.asc \
| sudo gpg --dearmor -o /etc/apt/keyrings/phisys-archive-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/phisys-archive-keyring.gpg] https://apt.phisys.com/testing $(. /etc/os-release && echo "$VERSION_CODENAME") main" \
| sudo tee /etc/apt/sources.list.d/phisys.list
sudo apt update
sudo apt install phis
The same packages are attached to each GitHub release, as
phis_<version>-<revision>+<distribution>_<arch>.deb (for example phis_0.2.7-1+trixie_amd64.deb),
for a host that installs without the repository (sudo apt install ./phis_<...>.deb). Debian 12
(bookworm) is not served: its glibc is older than the one the package is built against.
The package installs the server to
/usr/lib/phis/server, the CLI to
/usr/bin/phis, creates the
phis user and the stage
/srv/phis/stages/prod with an empty
config/phis-runtime.json, a link to
the packaged
phis-config.json (
/usr/share/phis/phis-config.json), and
.env holding
PORT=5301,
HOSTNAME=127.0.0.1 and a generated
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY. The service is
phis@prod.service. The server address is then
http://127.0.0.1:5301.
/usr/bin/phis operates on
/srv/phis/stages/prod unless
--root or
PHIS_ROOT names another stage (
CLI.md).
The package makes config/phis-runtime.json and .env mode 0600, owned by phis:phis, and sets that
again on every upgrade, because both hold secrets. An ordinary user therefore cannot run the CLI against
the stage: run it as the service account, sudo -u phis phis ..., or as root. A write as root keeps the
file's owner and group, so the server can still read it afterwards; the CLI creates the file mode 0600
whoever runs it. Continue with the configuration below.
From the package tarball
@phis/server is not on npm; the Debian package is the release (README, "Distribution"). The tarball
pnpm package:pack builds carries the same standalone server, the same operator CLI and the same schema
file as the Debian package, with the runtime dependencies resolved by npm instead of copied in beside them.
It has not been installed on a real host yet. What it
does not carry is the machine: no service account, no stage, no service unit, no proxy configuration, and no
generated
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY -- which production requires and every Instance of one
installation has to share. Take those from
debian/ where the installation needs them.
npm install -g ./phis-server-<version>.tgz
phis --help # the bin wrapper names the application root the CLI reads
The server is the standalone server.js in the package directory:
PHIS_ROOT=/srv/phis/stages/prod PORT=5301 HOSTNAME=127.0.0.1 \
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=... \
node "$(npm root -g)/@phis/server/server.js"
npm install warns that it did not run argon2's install script. It is not needed: both native packages
ship prebuilt binaries, and hashing and image processing work from the installed tarball as it is. Continue
with the configuration below.
The phis root
The phis root is the directory the server and the CLI read config/ from. The server takes it from
PHIS_ROOT, and in production falls back to /srv/phis/stages/prod. Create it, owned by the
service account:
install -d -o phis -g phis /srv/phis/stages/prod/config
In phis-config.json, check at least:
In phis-runtime.json, set the values the server needs. Generate each 64-character hex value with
openssl rand -hex 32:
{
"database": {
"uri": "postgresql://phis_prod_user:<serving password>@127.0.0.1:5432/phis_prod",
"migrationUri": "postgresql://phis_prod_migrator:<migration password>@127.0.0.1:5432/phis_prod"
},
"api": {
"internalToken": "<random token>"
},
"auth": {
"transactionEncryptionKey": "<64 hex>"
},
"registration": {
"intentEncryptionKey": "<64 hex>"
}
}
The example file validates as it is but holds placeholders, and it leaves out the two 64-hex keys
(auth.transactionEncryptionKey, registration.intentEncryptionKey), because a placeholder there
would not validate; add them as above.
Then write the secret store key and validate:
phis secret init-key # writes secrets.encryptionKey; every instance needs the same file
phis config validate
api.internalToken is what Sites authenticate with. The /setup finish step generates one if it is
missing; phis init (step 6) copies it into the Site and refuses to run while it is unset.
operator.sessionSecret signs the operator session cookies and is written by the same finish step, or
by phis operator add; it is deliberately not the internal token, which every Site holds.
In production the server also needs the environment variable
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
(base64 of 32 bytes:
head -c 32 /dev/urandom | base64) and refuses to start without it. Instances
of one installation share the value. Other server environment variables (
PORT,
HOSTNAME,
TRUST_PROXY,
PHIS_INSTANCE_ID) are described in
README.md.
3. Create the database schema
phis schema apply
phis schema check
apply creates the
phis schema, its tables and
pg_trgm as the serving role, and grants the
migration role what Add-on migrations need. It is idempotent and is run again after every server
upgrade (
CLI.md).
4. Start the server and create the first operator
Start the server with the phis root and a loopback bind:
# from source, in the phis-server checkout
PHIS_ROOT=/srv/phis/stages/prod NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<key> \
pnpm exec next start -H 127.0.0.1 -p 5301
# Debian package
systemctl enable --now phis@prod.service
The server is built with output: "standalone", so next start logs a warning that it does not work
with that setting and recommends the standalone server.js. The Debian package runs the standalone
server.js (see debian/rules for the files it takes from .next/standalone).
Before stopping a server process, phis instance drain withdraws its readiness; the Debian unit does
this in ExecStop.
Create the first platform operator, the account of the operator UI at /operator:
phis operator add --email ops@example.com --password '<password>' --name 'Operations'
The server caches the configuration for up to four hours: restart it after this command, or the new
operator cannot sign in yet. The same holds for every later CLI command that changes
phis-runtime.json (other operator commands, site add, site config set|add|del, secret
init-key), and for editing the file by hand.
Alternative: the setup pages
With bootstrap.enabled: true, the server serves /setup, a guided flow: runtime checks, first
operator (only while none exists), translation mode, database connection (writes database.uri),
schema (the same script as phis schema apply), an optional first Site, and finish, which writes api.internalToken if missing and sets
bootstrap.enabled: false in phis-runtime.json. The setup pages have no login; while
bootstrap.enabled is true, keep the server reachable only by the operator (loopback bind, SSH
tunnel). They do not write database.migrationUri, secrets.encryptionKey,
auth.transactionEncryptionKey or registration.intentEncryptionKey; set those as in step 2. A Site
created there gets no sites.overrides.<key>.sourceLocale (its source locale is
translation.sourceLocale) and no administrator; continue with phis site admin in step 5.
5. Create a Site
phis site add --key acme --name 'ACME' --hostname www.acme.example \
--public-base-url https://www.acme.example \
--source-locale en --default-locale en --available-locales en,de \
--support-email support@acme.example
This inserts the Site row and writes the immutable
sites.overrides.acme.sourceLocale to
phis-runtime.json; restart the server so it reads the override. All locales must be in
translation.supportedLocales. The key must match
[a-z0-9][a-z0-9._-]{0,63}
(
CLI.md).
Give the Site its first administrator, who signs in on the Site itself:
phis site admin --site acme --email admin@acme.example --password '<password>' \
--first-name Ada --last-name Admin
Without
--password the account gets an unknown random password; set one with
phis site user --site acme --email admin@acme.example --password '<password>' --overwrite
(
CLI.md).
phis site config runtime --key acme shows the Site's derived source path, instance path and port.
6. Scaffold the Site
phis init acme --path /opt/phis/sources/acme --api-base-url http://127.0.0.1:5301
The Site row must exist, and the target must not exist or be an empty directory; otherwise use
phis reconcile.
api.internalToken must be set in the phis root (step 2); init refuses otherwise.
--path defaults to <sites.sourcePathRoot>/<site key>.
--api-base-url is required: the address the Site's server-side code uses to reach
@phis/server, as seen from the host the Site runs on. The Debian package serves on
http://127.0.0.1:5301. Only init writes it into config/site-runtime.json.
--package-manager defaults to npm for a new Site.
What init writes (scaffold version 3):
config/site-runtime.json, .gitignore and the src/generated/ files belong to the Site after
init: reconcile leaves them as they are.
src/generated/ is ignored by git as well. The projection is what the operator installed, and its
source is the desired state in the phis root (config/phis-modules.json), not the Site's history: a
fresh clone gets the empty files from reconcile and the installed Modules from phis module sync. Every other file is canonical and is overwritten by
reconcile. Put Site-specific code in new files, not in canonical ones.
config/site-runtime.json is ignored by git, so a fresh clone of the Site has none: create it from
config/site-runtime.example.json on every host that builds or runs the Site. reconcile does not
create it.
The canonical files are thin wrappers: routes, proxy and Area hosts call into @phis/ui and hold no
CMS, media, locale or proxy logic of their own. A change to that shared behaviour reaches a Site by
updating @phis/ui and rebuilding. The scaffold contains no optional feature packages; Modules are
added only with phis module add (step 9), and reconcile keeps them.
Commit the repository.
7. Install, build and run the Site
phis update acme --path /opt/phis/sources/acme
update reconciles the scaffold, runs the Site's package manager (install, typecheck, build),
and copies the standalone build with .next/static and public/ into
/opt/phis/sources/acme/.phis/releases/<release id>/. The printed release id is the directory name.
Next nests the app inside .next/standalone when the Site is built inside a workspace; update finds
it there and writes a server.js at the top of the release that starts it, so every release starts
the same way. Equivalent by hand: npm install && npm run typecheck && npm run build in the Site
directory, without the release copy.
The release holds no copy of config/site-runtime.json and so no internal token. Its
config/site-runtime.json is a relative link to /opt/phis/sources/acme/config/site-runtime.json,
which the release reads when it starts. A release copied to another host needs that host's own file
in place of the link.
@phis/ui and the other dependencies must be resolvable by the package manager, from a registry or a
workspace.
Run the release from the instance path:
ln -sfn /opt/phis/sources/acme/.phis/releases/<release id> /var/www/phis/sites/acme
cd /var/www/phis/sites/acme
NODE_ENV=production HOSTNAME=127.0.0.1 PORT=52001 node server.js
The port is sites.basePort + site id (phis site config runtime --key acme prints it).
The Site reads config/site-runtime.json once per process, through the release's link from
/opt/phis/sources/acme/config/. After changing the file, restart the Site; no new release is
needed.
Rendered Public Area pages are kept in the process's memory, 128 MB per process by default. Set
PHIS_RENDER_CACHE_MB to change it and size the host's memory for all Site processes (see
STATIC_RENDERING.md in @phis/ui). Instances do not share the cache.
npm start (next start) in the source directory also serves the build, but warns that it does
not work with the scaffold's output: "standalone"; use server.js in production.
For development, npm run dev in the Site directory runs next dev.
8. Web server and service units
phis site config prints snippets; it writes nothing (
CLI.md).
phis site config systemd --key acme > /etc/systemd/system/phis-site-acme.service
phis site config nginx --key acme > /etc/nginx/sites-available/acme.conf
The systemd unit runs /usr/bin/node server.js as phis in the instance path with
NODE_ENV=production, HOSTNAME=127.0.0.1, PORT=<port> and Restart=always. It carries
#Environment=PHIS_RENDER_CACHE_MB=128 commented out; uncomment and change it if the default does not
fit. Check the Node path, then:
systemctl daemon-reload
systemctl enable --now phis-site-acme.service
A Site with several instances (phis site config add) gets one unit per instance:
phis site config systemd --key acme --instance <n> into phis-site-acme-<n>.service, numbered as
phis site config runtime --key acme lists them. A Site unit has no drain step; phis instance drain
is for server instances.
The nginx file belongs in nginx's http context, which is where Debian includes
sites-enabled/*. It holds a map that sets Connection to upgrade only for upgrade requests, an
upstream with every instance of the Site, keepalive 64 and keepalive_timeout 4s, and a server
block on port 80 for the Site's host name with client_max_body_size 2g, proxy_http_version 1.1,
Host $http_host and the forwarding headers. Keep keepalive_timeout below Node's keep-alive timeout
of 5 s when you edit it: otherwise nginx reuses connections Node has closed and POST requests fail
with 502 (the phis-host README, section "Web server in front of several instances", has the details
and the Apache equivalent). Add TLS (a listen 443 ssl block and certificates), enable it and reload
nginx. The Site's proxy routes forward /api/auth, /api/site, /api/addons and /api/hooks to the
server internally, so the server itself needs no public host name for the Site.
Expose the operator UI (/operator) only to operators.
The same keep-alive rule applies to a proxy in front of several server instances. The templates the
package installs (/usr/share/phis/templates/nginx/phis.conf and .../apache2/phis.conf, copied to
sites-available/phis.conf by postinst) carry it: an upstream phis pool with the instances the
package runs — phis@prod.service on 127.0.0.1:5301 and a commented line for a second one —
keepalive 64, keepalive_timeout 4s, the Connection map, and Host $http_host. Add a further
instance of the same stage as a further line in that pool. Another stage is another installation with
its own database: give it its own upstream and server block, never a line in this one. The Apache
template is a balancer://phis with ttl=4 per member, which is the same rule on that side.
A translation mirror
An installation that other installations read global translations from -- translation.cdnBaseUrl on
their side, cdn+deepl as their mode -- is published differently from a Site or an operator host. It
needs api.publicToken set and translation.allowPublicReads on, and its host name answers two paths
and nothing else:
server {
listen 443 ssl;
server_name cdn.example.test;
# certificates, HSTS
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location = /api/v1/tr {
proxy_pass http://127.0.0.1:5301;
}
location = /api/v1/locales {
proxy_pass http://127.0.0.1:5301;
}
location / {
return 404;
}
}
Exact matches, so
/api/v1/tr/anything is not a door too. The
404 is the point of the block: the
location / the templates above carry would publish the operator UI, the setup pages and every internal
/api/v1 route under this host name as well. They refuse a caller without the internal token, so an
unfiltered mirror is surface rather than a leak, but the setup pages have no login of their own. A mirror
is never published through a Site's proxy routes -- they set the internal token, which turns every
caller into a trusted one;
TRANSLATIONS.md, "How a mirror is served", has the
reasoning and what an edge cache in front would have to respect.
9. Add Modules
A Site Module is a package the Site build includes.
phis module add --site acme --package @phis/marketplace --spec 0.1.0 --path /opt/phis/sources/acme
phis update acme --path /opt/phis/sources/acme
module add records the Module in
config/phis-modules.json, adds the dependency when
--spec is
given, and rewrites
src/generated/site-modules*.ts — including
site-modules-fonts.ts, which lists
the installed packages that export
./fonts, and
site-module-packages.json, which
next.config.ts
reads so Next compiles every installed Module package. A package added before it is installed is not yet
readable, so run
phis module sync --site acme --path /opt/phis/sources/acme after the install to
pick up the typefaces it ships.
update installs and builds a new release;
deploy it (step 7) and restart the Site service. Then activate the Module for an Area in the Builder.
phis module check --site acme inspects the installed packages
(
CLI.md).
10. Add Add-ons
A server Add-on is one addon.mjs artifact, installed once per phis root and activated per Site.
phis addon install --artifact ./dist/addon.mjs
phis addon reconcile
phis addon migrate --addon @acme/status
phis addon enable --addon @acme/status
phis addon reconcile
If the Add-on has a Module half (the same package name), add it to the Site first (step 9), then
activate the Add-on for the Site:
phis addon site --addon @acme/status --site acme --enable
phis addon config --addon @acme/status --site acme # list settings
phis addon config --addon @acme/status --site acme --name <setting> --value <value>
Secrets the Add-on declares are stored with
phis secret set --addon @acme/status --site acme --name
<name> (value on stdin). Roles are granted with
phis addon role
(
CLI.md).
Core schedules nothing. An Add-on that declares jobs (
phis addon list --json shows each manifest,
its
jobs included) runs them only when something calls
phis addon job, the notification outbox is
retried only by
phis mail drain, and Core's cleanup tasks run with
phis maintain. On a systemd host,
give each a one-shot service and a timer:
SERVER_ADDONS.md has
an example for a job, and the same pair of units with another
ExecStart= serves the other two.
11. Updating later
Server
Install the new server release -- sudo apt update && sudo apt install phis from the Debian package, or
pull and rebuild the checkout (step 2) from source. The Debian package restarts every running
phis@<stage> instance itself once the new release is in place, one after the other; from source the
restart below is yours. Then:
phis schema apply
phis addon check
phis addon migrate
phis addon reconcile
From source, restart the server instances one by one (phis instance drain first). Then check
phis instance list, and run phis site check --key acme against each Site.
Site
phis update acme --path /opt/phis/sources/acme
update runs reconcile first, which:
rewrites the canonical scaffold files to the current scaffold (Site edits to them are lost);
adds to package.json what the scaffold needs and the Site lacks, keeping every version, script
and field the Site already has; only engines.node is set to >=24.21.0. To move to a newer
@phis/ui or Next, raise the version in the Site (npm install @phis/ui@<version>) and then run update;
keeps the Site-owned files (PHI_SITE_OWNED_AFTER_INIT: config/site-runtime.json, .gitignore,
src/generated/site-modules*.ts) as they are;
removes files an earlier scaffold wrote and the current one no longer does, if their content is
unchanged, and reports edited ones as kept, which you move or delete by hand;
uses the Site's own package manager and deletes no lockfile, unless --package-manager names a
different one.
A Site scaffolded before .phis/releases/ was part of the generated .gitignore needs that line
added by hand; reconcile does not rewrite .gitignore.
Review the changes (phis reconcile acme --json lists every file with its action, and git diff
shows them), then deploy the new release (step 7) and restart the Site service. To change the
server address or token of an existing Site, edit config/site-runtime.json yourself; reconcile
does not rewrite it.