Run Polyant with systemd
On this page
- What you end up with
- 1. Install the prerequisites
- 2. Create the database
- 3. Create the service user and get the code
- 4. Build the engine and the panel
- 5. Write the environment file
- 6. Point the writable folders outside the code
- 7. Create the units
- 8. Start the services
- Logs
- What happens on a restart
- Backups and restore
- Upgrading
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
| Unit | Port | Runs | Writes to |
|---|---|---|---|
postgresql.service | 5432 | the distribution’s PostgreSQL 16 cluster | /var/lib/postgresql/16/main |
polyant-engine.service | 4000 | migrations, then the engine (packages/engine/dist/index.js) | /var/log/polyant, /var/lib/polyant |
polyant-web.service | 3000 | the 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 --versionnode --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/polyantThe 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:dockercompiles 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: thecpdoes. - The panel builds as a Next.js standalone server. It does not include the static assets and the
public/folder, so the twocpcommands put them next toserver.js. - The panel’s calls to
/api/*are forwarded to the engine at the address inNEXT_PUBLIC_API_URL, which is fixed when the panel is built. Left unset, it ishttp://localhost:4000, which is right when the engine runs on the same host. If the engine runs elsewhere, set the variable in the environment ofnpm 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.envThe file must show -rw-r----- root polyant. Both services read the same file; each one ignores the variables that belong to the other.
| Variable | Read by | Required | Notes |
|---|---|---|---|
DATABASE_URL | engine, panel | yes, or the POSTGRES_* variables | The 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_KEY | engine | yes | 64 hex characters. It encrypts every agent secret in the database: back it up (see Backups). |
AUTH_SECRET | engine, panel | yes | At least 32 characters, the same value for both services. |
AUTH_INTERNAL_SECRET | engine, panel | yes | Email and password sign-in is the only sign-in of the open-source build: without this value nobody can log in. |
AUTH_TRUST_HOST | panel | yes | Must be true on any self-hosted panel. |
INITIAL_ADMIN_PASSWORD | engine | first boot | Creates 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_EMAIL | engine | no | Defaults to administrator@local. |
NODE_ENV | engine, panel | recommended | production turns on the strict CORS default, the quieter framework logs and the panel’s security headers. |
BASE_URL | engine | for channels | The 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_ORIGINS | engine | no | Not needed when browsers reach the engine only through the panel, as in this setup. |
TRUST_PROXY | engine | behind a proxy | Number of proxy hops in front of the engine. |
INTERNAL_ENGINE_URL | panel | no | Where the panel checks credentials. Defaults to http://localhost:4000. |
API_PORT | engine | no | Defaults to 4000. |
LOG_LEVEL | engine | no | debug, 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.env6. 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/cacheThe 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
EOFsudo 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
EOFAfter= 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/healthThe 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-engineLogs
| What | Where |
|---|---|
| Everything both services print, migrations included | the 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
| Event | Result |
|---|---|
| The server reboots | PostgreSQL starts, then the engine runs the migrations and starts, then the panel starts. |
| The engine process is killed | systemd starts it again after 5 seconds (RestartSec), migrations first. |
| The panel process is killed | systemd starts it again after 5 seconds. |
systemctl stop polyant-engine | The engine stays stopped until systemctl start. |
| A migration fails | The 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:
- The database. It holds the users, the agents with their settings, the conversations, the memories, the documents and every agent secret in encrypted form.
/etc/polyant/polyant.env. Above allENCRYPTION_KEY: a database restored under a different key starts normally, but every read of an encrypted secret fails, and the API answers500withUnsupported state or unable to authenticate datain the engine log.- The two unit files in
/etc/systemd/system/. /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.- 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-webpg_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_oldUpgrading
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-webReplace 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.