Skip to Content
Polyant is open source under AGPL-3.0 — star us on GitHub.
Operationssystemd (single host)

Run Polyant with systemd

On this page

This page installs Polyant on a single Linux server without containers: PostgreSQL 16 with pgvector from the distribution, the engine and the admin panel built from source, each one run by systemd as an unprivileged user. Both services start at boot after PostgreSQL, come back on their own if their process dies, and log to the journal.

The commands were tested on Ubuntu 24.04 (arm64, in containers running systemd as PID 1) with Node 24 and PostgreSQL 16. Run them as a user with sudo. For the container recipes, see Deployment.

What you end up with

UnitPortRunsWrites to
postgresql.service5432the distribution’s PostgreSQL 16 cluster/var/lib/postgresql/16/main
polyant-engine.service4000migrations, then the engine (packages/engine/dist/index.js)/var/log/polyant, /var/lib/polyant
polyant-web.service3000the admin panel (Next.js standalone server)/var/cache/polyant-web

The code lives in /opt/polyant and belongs to root: the services can read it and run it, but not change it. The secrets live in one file, /etc/polyant/polyant.env, readable by root and the polyant group only.

1. Install the prerequisites

sudo apt-get update sudo apt-get install -y git curl openssl postgresql-16 postgresql-16-pgvector sudo systemctl enable --now postgresql curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh sudo bash /tmp/nodesource_setup.sh sudo apt-get install -y nodejs node --version

node --version must print v24.x: the engine and the panel target Node 24 (.nvmrc names it).

2. Create the database

PGPASSWORD_POLYANT=$(openssl rand -hex 24) sudo -u postgres psql -c "CREATE ROLE polyant LOGIN PASSWORD '$PGPASSWORD_POLYANT';" sudo -u postgres createdb -O polyant polyant sudo -u postgres psql -d polyant -c "CREATE EXTENSION IF NOT EXISTS vector;"

Keep this shell open: step 5 writes $PGPASSWORD_POLYANT into the environment file. A hex password needs no escaping inside DATABASE_URL.

The extension is created here, as the postgres superuser, because the polyant role is not a superuser and the first migration runs CREATE EXTENSION IF NOT EXISTS vector. Without this line the engine does not start, and its journal shows permission denied to create extension "vector" with the hint Must be superuser to create this extension.

3. Create the service user and get the code

sudo useradd --system --user-group --home-dir /var/lib/polyant --create-home --shell /usr/sbin/nologin polyant sudo git clone --branch v1.2.0 https://github.com/polyant-ai/polyant.git /opt/polyant

The clone checks out the release tag. Replace v1.2.0 with the release you want to install.

4. Build the engine and the panel

cd /opt/polyant sudo npm ci sudo npm run build:docker -w @polyant/engine sudo cp -r packages/engine/src/database/migrations packages/engine/dist/database/ sudo npm run build:web sudo cp -r packages/web/.next/static packages/web/.next/standalone/packages/web/.next/ sudo cp -r packages/web/public packages/web/.next/standalone/packages/web/

What each step is for:

  • build:docker compiles the engine without its test files, the same build the engine image uses.
  • The compiled migration runner reads its SQL files from packages/engine/dist/database/migrations, and the TypeScript compiler does not copy them there: the cp does.
  • The panel builds as a Next.js standalone server. It does not include the static assets and the public/ folder, so the two cp commands put them next to server.js.
  • The panel’s calls to /api/* are forwarded to the engine at the address in NEXT_PUBLIC_API_URL, which is fixed when the panel is built. Left unset, it is http://localhost:4000, which is right when the engine runs on the same host. If the engine runs elsewhere, set the variable in the environment of npm run build:web; changing it later needs a rebuild, not a restart.

5. Write the environment file

sudo install -d -m 750 -o root -g polyant /etc/polyant sudo install -m 640 -o root -g polyant /dev/null /etc/polyant/polyant.env sudo tee /etc/polyant/polyant.env >/dev/null <<EOF NODE_ENV=production DATABASE_URL=postgresql://polyant:$PGPASSWORD_POLYANT@localhost:5432/polyant ENCRYPTION_KEY=$(openssl rand -hex 32) AUTH_SECRET=$(openssl rand -hex 32) AUTH_INTERNAL_SECRET=$(openssl rand -hex 32) AUTH_TRUST_HOST=true [email protected] INITIAL_ADMIN_PASSWORD=$(openssl rand -base64 18) BASE_URL=http://localhost:4000 EOF sudo ls -l /etc/polyant/polyant.env

The file must show -rw-r----- root polyant. Both services read the same file; each one ignores the variables that belong to the other.

VariableRead byRequiredNotes
DATABASE_URLengine, panelyes, or the POSTGRES_* variablesThe connection to PostgreSQL. The panel’s Auth.js adapter uses the same database. The individual POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD are an alternative to it.
ENCRYPTION_KEYengineyes64 hex characters. It encrypts every agent secret in the database: back it up (see Backups).
AUTH_SECRETengine, panelyesAt least 32 characters, the same value for both services.
AUTH_INTERNAL_SECRETengine, panelyesEmail and password sign-in is the only sign-in of the open-source build: without this value nobody can log in.
AUTH_TRUST_HOSTpanelyesMust be true on any self-hosted panel.
INITIAL_ADMIN_PASSWORDenginefirst bootCreates the first administrator when the users table is empty. Without it no administrator is created and the engine logs that it skipped the seed.
INITIAL_ADMIN_EMAILenginenoDefaults to administrator@local.
NODE_ENVengine, panelrecommendedproduction turns on the strict CORS default, the quieter framework logs and the panel’s security headers.
BASE_URLenginefor channelsThe public address of the engine, used to build webhook URLs. With channels it must be a public HTTPS address; a platform admin can also set it in the panel.
CORS_ORIGINSenginenoNot needed when browsers reach the engine only through the panel, as in this setup.
TRUST_PROXYenginebehind a proxyNumber of proxy hops in front of the engine.
INTERNAL_ENGINE_URLpanelnoWhere the panel checks credentials. Defaults to http://localhost:4000.
API_PORTenginenoDefaults to 4000.
LOG_LEVELenginenodebug, info (default), warn, error or silent. At info the log keeps the start of every message and reply: see Logs.

The complete list is in Environment variables. Model provider keys are not environment variables: they are set per agent in the panel.

To read the first administrator’s password:

sudo grep INITIAL_ADMIN_PASSWORD /etc/polyant/polyant.env

6. Point the writable folders outside the code

sudo ln -s /var/log/polyant /opt/polyant/logs sudo ln -s /var/lib/polyant/workspaces /opt/polyant/packages/engine/workspaces sudo ln -s /var/cache/polyant-web /opt/polyant/packages/web/.next/standalone/packages/web/.next/cache

The engine writes its daily log file to logs/ at the root of the code and the conversation workspaces to packages/engine/workspaces; no supported variable moves either path. The panel writes its image cache under .next/cache. The links send all three to folders that systemd creates and gives to the polyant user when the services start.

7. Create the units

sudo tee /etc/systemd/system/polyant-engine.service >/dev/null <<'EOF' [Unit] Description=Polyant engine (API, channels, agents) Documentation=https://docs.polyant.ai/operations/systemd Wants=network-online.target postgresql.service After=network-online.target postgresql.service [Service] Type=simple User=polyant Group=polyant WorkingDirectory=/opt/polyant EnvironmentFile=/etc/polyant/polyant.env # Apply pending database migrations before every start. They run under a lock, # in one transaction: if one fails, all the pending ones are rolled back. ExecStartPre=/usr/bin/node packages/engine/dist/database/migrate.js ExecStart=/usr/bin/node packages/engine/dist/index.js Restart=always RestartSec=5 TimeoutStartSec=300 # Writable state: /var/lib/polyant (conversation workspaces) and # /var/log/polyant (daily log files). Everything else is read-only. StateDirectory=polyant polyant/workspaces LogsDirectory=polyant StateDirectoryMode=0750 LogsDirectoryMode=0750 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true [Install] WantedBy=multi-user.target EOF
sudo tee /etc/systemd/system/polyant-web.service >/dev/null <<'EOF' [Unit] Description=Polyant admin panel (Next.js) Documentation=https://docs.polyant.ai/operations/systemd Wants=network-online.target polyant-engine.service After=network-online.target polyant-engine.service [Service] Type=simple User=polyant Group=polyant WorkingDirectory=/opt/polyant/packages/web/.next/standalone EnvironmentFile=/etc/polyant/polyant.env Environment=PORT=3000 HOSTNAME=0.0.0.0 ExecStart=/usr/bin/node packages/web/server.js # The Next.js server exits with 143 on SIGTERM: a normal stop, not a failure. SuccessExitStatus=143 Restart=always RestartSec=5 # Writable state: the Next.js image cache only. CacheDirectory=polyant-web CacheDirectoryMode=0750 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true [Install] WantedBy=multi-user.target EOF

After= sets the order, not a guarantee. On Debian and Ubuntu, After=postgresql.service makes the engine start once PostgreSQL accepts connections: the postgresql@16-main cluster unit starts before postgresql.service and returns only when the server is up. Wants= does not stop a unit when its dependency fails: if the engine cannot start, the panel starts anyway, with no engine behind it, while systemd keeps retrying the engine every 5 seconds. With a PostgreSQL on another host, remove postgresql.service from Wants= and After=.

Restart=always brings a service back whatever stopped its process, a crash, the out-of-memory killer or a stray kill: the engine exits cleanly on SIGTERM, so Restart=on-failure would leave it down after one. systemctl stop still stops it.

8. Start the services

sudo systemctl daemon-reload sudo systemctl enable --now polyant-engine.service polyant-web.service systemctl is-active postgresql polyant-engine polyant-web curl -s --retry 10 --retry-connrefused --retry-delay 1 http://localhost:4000/health

The three units print active and the health check answers {"status":"ok",...}. A unit is active as soon as its process has started, while the engine needs a few more seconds to open port 4000: that is why curl retries. The engine’s journal ends with Polyant ready!.

Ports 3000 and 4000 serve plain HTTP on every interface. Do the first sign-in from a trusted network, or through an SSH tunnel to the server, and put an HTTPS reverse proxy in front of both services before opening them to anyone else: follow the reverse proxy checklist and keep the two ports closed to the outside in the firewall.

Sign in on port 3000 with INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD, and choose a new password: the panel asks for it before anything else. Then create an agent and give it a model provider key, as in the Quickstart.

Once you have signed in, the seed variable is no longer used, because the users table is not empty. Remove it so the file no longer holds a password nobody needs:

sudo sed -i '/^INITIAL_ADMIN_PASSWORD=/d' /etc/polyant/polyant.env sudo systemctl restart polyant-engine

Logs

WhatWhere
Everything both services print, migrations includedthe journal: journalctl -u polyant-engine -f, journalctl -u polyant-web -f
The engine’s structured log/var/log/polyant/engine-YYYY-MM-DD.log, one file per UTC day, the last 14 kept

The daily file carries the engine’s own log lines, for example a request that failed with its route and status; the journal also carries the migrations and the framework’s start-up lines.

At the default LOG_LEVEL=info both places keep the first 80 characters of every incoming message and up to 500 characters of every reply. Treat the logs as conversation data and limit who can read the journal and /var/log/polyant. Setting LOG_LEVEL=warn in the environment file removes these message and reply excerpts; warnings and errors can still quote conversation content, for example a channel message that failed to send.

What happens on a restart

EventResult
The server rebootsPostgreSQL starts, then the engine runs the migrations and starts, then the panel starts.
The engine process is killedsystemd starts it again after 5 seconds (RestartSec), migrations first.
The panel process is killedsystemd starts it again after 5 seconds.
systemctl stop polyant-engineThe engine stays stopped until systemctl start.
A migration failsThe engine does not start; systemd retries every 5 seconds and the journal shows the failing query. The pending migrations run in one transaction and are rolled back together.

Backups and restore

Keep these:

  1. The database. It holds the users, the agents with their settings, the conversations, the memories, the documents and every agent secret in encrypted form.
  2. /etc/polyant/polyant.env. Above all ENCRYPTION_KEY: a database restored under a different key starts normally, but every read of an encrypted secret fails, and the API answers 500 with Unsupported state or unable to authenticate data in the engine log.
  3. The two unit files in /etc/systemd/system/.
  4. /var/lib/polyant/workspaces, if agents use the file tools and their output must survive: the files those tools write live there, not in the database.
  5. The agents’ attachment buckets, if Store attachments is on: the database holds only references to the files in S3.

The logs are optional.

Back up the database. The dump is written to a temporary name and renamed only when pg_dump succeeds, so a failed run never replaces a good backup:

sudo install -d -m 700 /var/backups/polyant sudo sh -c 'f=/var/backups/polyant/polyant-$(date +%F-%H%M%S).dump; sudo -u postgres pg_dump -Fc polyant > "$f.tmp" && mv "$f.tmp" "$f" && ls -l "$f"'

A failed run prints the pg_dump error and leaves only a .tmp file, which can be deleted.

Restore it into a new database, check that it worked, and only then swap it in, with the same ENCRYPTION_KEY in the environment file. Run the commands one at a time and stop at the first error; set DUMP to the file to restore:

DUMP=/var/backups/polyant/polyant-YYYY-MM-DD-HHMMSS.dump sudo pg_restore --list "$DUMP" > /dev/null sudo -u postgres createdb -O polyant polyant_restore sudo cat "$DUMP" | sudo -u postgres pg_restore --single-transaction --exit-on-error -d polyant_restore sudo systemctl stop polyant-web polyant-engine sudo -u postgres psql -c "ALTER DATABASE polyant RENAME TO polyant_old;" -c "ALTER DATABASE polyant_restore RENAME TO polyant;" sudo systemctl start polyant-engine polyant-web

pg_restore --list reads the archive’s table of contents without touching a database, so a wrong path or a truncated file stops here. It does not check the data of every table: the restore into polyant_restore does, and it must finish without errors before the swap. The dump goes through sudo cat because neither your user nor postgres can read the root-only backup folder. The restore recreates the vector extension, since pg_restore runs as the superuser. On a new server, where there is no polyant database yet, rename only polyant_restore. When the restored installation works, drop the old copy:

sudo -u postgres dropdb polyant_old

Upgrading

Read Upgrading for the release first, and take a backup. The upgrade stops both services for the length of the build:

cd /opt/polyant sudo systemctl stop polyant-web polyant-engine sudo git fetch --tags sudo git checkout vX.Y.Z sudo npm ci sudo rm -rf packages/engine/dist sudo npm run build:docker -w @polyant/engine sudo cp -r packages/engine/src/database/migrations packages/engine/dist/database/ sudo npm run build:web sudo cp -r packages/web/.next/static packages/web/.next/standalone/packages/web/.next/ sudo cp -r packages/web/public packages/web/.next/standalone/packages/web/ sudo ln -sfn /var/cache/polyant-web /opt/polyant/packages/web/.next/standalone/packages/web/.next/cache sudo systemctl start polyant-engine polyant-web

Replace vX.Y.Z with the tag of the release you are upgrading to. The engine build starts from an empty dist, so a file removed in the new release does not survive as compiled code. The panel build replaces .next/standalone, so the static assets and the cache link have to be put back after every build. The engine applies the new migrations when it starts.

Last updated on