Self-Hosted Package Installation Guide
Deploy ManageLM on bare-metal or VM with the self-extracting installer.
Overview
The ManageLM installer is a single self-extracting .sh.gz file that includes the portal, agent source, and a bundled Node.js runtime. No prerequisites need to be installed on the target server — just download, extract, and run.
| What's included | Description |
|---|---|
| ManageLM Portal | Web UI, API, MCP server, task engine |
| Agent source | Served to managed hosts for agent installation and auto-updates |
| Node.js runtime | Bundled runtime — no system Node.js needed |
The installer handles both first install and upgrades. On upgrade, your configuration (.env) is preserved automatically.
Requirements
- Linux (x64 or arm64) — Debian, Ubuntu, RHEL, Rocky, AlmaLinux, etc.
- PostgreSQL 15+ — application database
- Redis 7+ (or Valkey) — sessions, pub/sub, rate limiting
- 1 GB RAM minimum
- Two HTTPS endpoints:
- Portal URL (
SERVER_URL, e.g.https://managelm.example.com) — admin UI + agent WebSocket. Can be internal-only. The certificate just needs to be trusted by every agent host: a public CA (Let's Encrypt etc.) works without setup; a private/internal CA works too if you distribute its root cert to the agents' system trust store before enrolling them. See Internal Portal URL. - MCP public URL (
PUBLIC_URL, e.g.https://mcp.example.com) — required for Claude.ai Custom Connector. Must be a dedicated subdomain on a public DNS record, reachable from the internet, with a certificate from a public CA (Anthropic's servers won't accept a private CA). See MCP Public URL.
- Portal URL (
Node.js is bundled in the installer — no system packages required beyond the database and cache.
Download
Download the latest release for your architecture from GitHub Releases.
Then extract and run:
gunzip managelm-<version>-linux-<arch>.sh.gz
Install
Run the installer as root:
sudo bash managelm-<version>-linux-<arch>.sh
The installer will prompt for:
| Prompt | Default | Description |
|---|---|---|
| Service user | managelm | System user the portal runs as |
| Portal port | 3000 | HTTP listen port (behind reverse proxy) |
| Register systemd service? | Y | Creates and enables managelm-server.service |
For unattended installation, use --yes to accept all defaults:
sudo bash managelm-<version>-linux-<arch>.sh --yes
Other flags:
--install-dir DIR Override install directory (default: /opt/managelm-server)
--user USER Service user (default: managelm)
--port PORT Portal port (default: 3000)
--no-systemd Skip systemd service registration
Configure
Set up PostgreSQL and Redis first — see PostgreSQL Setup and Redis Setup below for the full procedure (creating the user, granting schema access, enabling persistence). The portal will fail to start until both are reachable with valid credentials.
Once PostgreSQL and Redis are running, edit the .env file:
sudo vi /opt/managelm-server/portal/.env
At minimum, set these values:
# How browsers and agents reach the portal
SERVER_URL=https://managelm.example.com
# Public MCP URL for Claude.ai (dedicated subdomain, public CA)
# Required for the Claude Custom Connector flow; leave unset for testing.
# See "MCP Public URL" below for the reverse-proxy setup.
PUBLIC_URL=https://mcp.example.com
# PostgreSQL connection (created in the PostgreSQL Setup section below)
DATABASE_URL=postgresql://managelm:your-password@localhost:5432/managelm
# Redis connection
REDIS_URL=redis://localhost:6379
# Sender email (only effective when SMTP_HOST is also set)
[email protected]
Then start the service:
systemctl start managelm-server
Check it's running:
systemctl status managelm-server
curl -s http://localhost:3000/health
First Steps After Install
Once the portal is running:
- Front the portal with TLS — reverse proxy at
SERVER_URL(nginx or Apache). Required before agents can enroll. - Set up the MCP public URL — dedicated subdomain (e.g.
mcp.example.com) reverse-proxied to portal/public/. Required for Claude.ai Custom Connector. - Register your account at your
SERVER_URL. The first user becomes the account owner. - Configure the LLM — Local (Ollama), Cloud, or Proxied access mode.
- Import skills from the built-in catalog.
- Install an agent on your first server.
- Connect Claude via MCP.
Internal Portal URL
Place a reverse proxy in front of the portal for TLS. This fronts SERVER_URL — admin UI, agent WebSocket, OAuth login/consent. The separate public-facing MCP endpoint is covered in MCP Public URL below.
WebSocket support is required for agent connections.
TRUSTED_PROXIES when fronting the portal. The proxy sets X-Forwarded-For with the real client IP, but the portal only honors it when the connecting socket is in TRUSTED_PROXIES. Without this, every request looks like it came from the proxy — audit logs, rate limits, GeoIP, and the MCP per-user IP whitelist all break. Use 127.0.0.1 when the proxy runs on the same host as the portal. See Environment Variables for details.
- Public domain, private IPs: use Let's Encrypt with the DNS-01 challenge — works for any domain you control via DNS, regardless of whether the portal is reachable from the public internet. This is the simplest path for most internal deployments.
- Internal (private) CA: distribute the CA root certificate to every agent host's system trust store (
/etc/pki/ca-trust/source/anchors/on RHEL family,/usr/local/share/ca-certificates/on Debian/Ubuntu) before enrolling the agent. - Plain HTTP / no TLS: agents will connect over
ws://, but credentials and task payloads travel unencrypted. Acceptable only for local testing on a trusted LAN, never for production.
nginx
server {
listen 443 ssl http2;
server_name managelm.example.com;
ssl_certificate /etc/ssl/certs/managelm.pem;
ssl_certificate_key /etc/ssl/private/managelm.key;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
Apache
<VirtualHost *:443>
ServerName managelm.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/certs/managelm.pem
SSLCertificateKeyFile /etc/ssl/private/managelm.key
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:3000/$1 [P,L]
ProxyTimeout 86400
</VirtualHost>
Upgrade and Connection headers for WebSocket.
MCP Public URL
Claude.ai's Custom Connector flow runs OAuth and every MCP tool call from Anthropic's backend, so the MCP endpoint has to be reachable from the public internet. ManageLM exposes a small curated /public/ surface on the portal — OAuth discovery, token exchange, MCP, the connector logo, and (when you opt-in per-agent) the agent bootstrap + WebSocket so agents outside your network can enroll. Everything else (admin UI, agent UI, login, internal APIs) stays on SERVER_URL and is not reachable from the public hostname.
You point Claude (and any external agents) at a dedicated public subdomain that reverse-proxies to http://127.0.0.1:3000/public/. Single rule, allowlist enforced by the portal.
Requirements
- Dedicated subdomain — e.g.
mcp.example.com. Don't share with the portal or other services; the well-known OAuth discovery URL must sit at the root of this hostname. - Public DNS — the name must resolve from outside your network (Anthropic's backend fetches it).
- Trusted public CA — Let's Encrypt or commercial. Private CAs are rejected by Anthropic's servers.
- Port 443 open from the internet to your reverse proxy.
Set PUBLIC_URL
In /opt/managelm-server/portal/.env:
PUBLIC_URL=https://mcp.example.com
Then restart: systemctl restart managelm-server. The portal advertises this URL in OAuth metadata and shows it in Settings → Claude Connectors as the URL users paste into Claude.
nginx
server {
listen 443 ssl http2;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000/public/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
Apache
<VirtualHost *:443>
ServerName mcp.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/mcp.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/mcp.example.com/privkey.pem
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:3000/public/
ProxyPassReverse / http://127.0.0.1:3000/public/
</VirtualHost>
Verify
# OAuth discovery — must return JSON metadata
curl -s https://mcp.example.com/.well-known/oauth-authorization-server | head
# MCP endpoint — must return 401 with WWW-Authenticate
curl -i -X POST https://mcp.example.com/mcp -H 'Content-Type: application/json' -d '{}'
# Anything outside the allowlist — must return 404
curl -i https://mcp.example.com/api/auth/me
/oauth/authorize on the public hostname. User login + consent happens on SERVER_URL only — this is what keeps stolen-password attacks from completing OAuth from outside your network. The portal refuses /oauth/authorize server-side when reached via the public hostname, but a correctly written reverse-proxy rule (just / → /public/) never even sends that request.
External agents
The same /public/ surface also serves the agent bootstrap and WebSocket, so agents that can't reach SERVER_URL (remote hosts, customer servers, anything outside your VPN) can enroll and connect via the public hostname instead.
In Agents → Add Agent (or Agent detail → Reinstall), flip the Use Public URL switch before copying the install command. The toggle is only shown when PUBLIC_URL is configured. With it on, the generated curl/wget/iex command points at mcp.example.com and the agent's config.yaml stores that URL as its server_url — the agent uses it for bundle download, enrollment polling, updates, and the runtime WebSocket. With it off, the install uses SERVER_URL (the original behaviour).
Threat-alert actions
When PUBLIC_URL is set, the one-click buttons in threat-alert emails (Kill Service / Kill Session / Discard Alert) are served via the public hostname too, so an admin can act on an alert from anywhere — no VPN needed. The authorization is the single-use signed token in the link, not a portal session, and the confirmation page is fully self-contained. No extra proxy rule is required — the existing / → /public/ rule already covers it. The “Open the alert in the portal” link in the same email still points at SERVER_URL, since it loads the admin UI and stays internal.
The choice is per-install: re-enrolling an existing agent re-prompts, so an agent that was originally internal can move to public (or vice versa) without portal-side surgery.
/ws/agent rides through the same / → /public/ rule — modern Apache (2.4.47+), nginx with proxy_set_header Upgrade $http_upgrade + Connection "upgrade" on the location block, and haproxy in HTTP mode all forward the upgrade transparently. Set the proxy's idle timeout to at least 1 hour so long-running agent sessions don't get killed.
SMTP / Email
The portal sends emails for account verification, password resets, and team invitations. When SMTP_HOST is not set, email sending is disabled entirely — no connection attempts are made.
To enable emails, configure an SMTP relay (Brevo, Mailgun, SendGrid, etc.):
SMTP_HOST=smtp.brevo.com
SMTP_PORT=587
SMTP_SECURE=starttls
SMTP_USER=your-username
SMTP_PASS=your-password
PostgreSQL Setup
ManageLM requires PostgreSQL 15+ (16 recommended). The portal runs migrations automatically on startup and needs full control over its schema.
-
Create the database and user
# Connect as a PostgreSQL superuser sudo -u postgres psql -- Create the ManageLM user CREATE USER managelm WITH PASSWORD 'your-strong-password'; -- Create the database owned by that user CREATE DATABASE managelm OWNER managelm; -- Connect to the new database \c managelm -- Grant full schema permissions (required for migrations) GRANT ALL ON SCHEMA public TO managelm; -- PostgreSQL 15+ changed defaults — grant CREATE explicitly ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO managelm; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO managelm; \q -
Allow remote connections (if PostgreSQL is on a different host)
# In postgresql.conf listen_addresses = '*' # In pg_hba.conf — allow the portal host host managelm managelm 10.0.0.0/8 scram-sha-256Restart PostgreSQL:
systemctl restart postgresql -
Set
DATABASE_URLin.envDATABASE_URL=postgresql://managelm:your-strong-password@localhost:5432/managelm -
Test the connection
psql "postgresql://managelm:your-strong-password@localhost:5432/managelm" -c "SELECT 1;"
DB_SSL=require in .env. To verify the server certificate, use DB_SSL=verify-ca with DB_SSL_CA=/path/to/ca.pem.
Redis Setup
ManageLM requires Redis 7+ (or Valkey). It is used for real-time agent communication, session state, and pub/sub — it must be available at all times.
-
Enable persistence (recommended)
# In redis.conf appendonly yes appendfsync everysec -
Set
REDIS_URLin.env# Without authentication REDIS_URL=redis://localhost:6379 # With authentication (Redis 6+ ACL) REDIS_URL=redis://username:password@redis-host:6379 -
Test the connection
redis-cli ping # Expected: PONG
REDIS_TLS=on in .env for encrypted connections. Use a rediss:// URL scheme.
Environment Variables
All settings are in /opt/managelm-server/portal/.env. The full reference is the same as the Docker guide.
Required
| Variable | Description |
|---|---|
SERVER_URL |
The URL where browsers and agents reach the portal — admin UI, agent WebSocket, OAuth login. This is the internal-facing URL (see PUBLIC_URL below for the separate Claude.ai endpoint). Agents derive their WebSocket connection from this value.
Behind a reverse proxy (recommended for production): use the proxy's URL with https://. Do not include the portal port — the proxy listens on 443.Example: https://managelm.example.com
Without a reverse proxy (testing / LAN): use the server IP or hostname with http:// and include the portal port.Example: http://192.168.1.10:3000
|
PUBLIC_URL |
Public-facing URL for the Claude.ai Custom Connector flow — OAuth discovery, token exchange, and MCP API. Must be a dedicated subdomain with public DNS, a trusted public CA, and port 443 open to the internet. Optional for testing; required for production with Claude.ai.
Example: https://mcp.example.com
See MCP Public URL for the reverse-proxy configuration. Ignored in SaaS mode. |
DATABASE_URL | PostgreSQL connection string |
REDIS_URL | Redis connection string |
SMTP_FROM | Sender email address. Validated at startup but only effective when SMTP_HOST is also set; otherwise no emails are sent. |
Strongly recommended
Set this before adding cloud connectors, configuring an LLM API key, enrolling agents, or generating a PKI CA — the portal starts without it (warning only), but any feature that encrypts data at rest will fail until it's configured. Rotating the key invalidates existing encrypted rows, so set it once and keep it.
| Variable | Default | Description |
|---|---|---|
ENCRYPTION_KEY | — | AES-256 master key for secrets at rest: cloud connector credentials, LLM API keys, agent signing keys, PKI CA private keys. Must be a 64-character hex string. Generate with: openssl rand -hex 32 |
TRUSTED_PROXIES | — |
Comma-separated IPs / CIDRs whose X-Forwarded-* headers Fastify will honor. Set this whenever the portal sits behind a reverse proxy — otherwise audit logs, rate limits, geo enrichment, and the MCP per-user IP whitelist all see the proxy's address instead of the real client.With nginx / Apache on the same host as the portal, 127.0.0.1 is the right value. For a proxy on a different host, use that host's IP (or a CIDR). Accepts the special token loopback (= 127.0.0.1 + ::1).Leave empty if the portal is reached directly — without a proxy in the path, trusting XFF from anyone would let clients spoof their own IP. Example: TRUSTED_PROXIES=127.0.0.1
|
Database & Redis
| Variable | Default | Description |
|---|---|---|
DB_SSL | none | SSL mode (none, require, verify, verify-ca) |
DB_SSL_CA | — | Path to CA certificate file (used with DB_SSL=verify-ca) |
DB_POOL_MAX | 20 | Maximum PostgreSQL connections per portal worker |
REDIS_TLS | auto | Redis TLS (auto = on for rediss:///valkeys://, on = force, off = skip) |
REDIS_DB | 0 | Logical database number (0–15). Useful when sharing a Redis instance. |
SMTP & DKIM
| Variable | Default | Description |
|---|---|---|
SMTP_HOST | — | SMTP server hostname. When empty, email is disabled (no connection attempts). |
SMTP_PORT | 25 | SMTP port (587 for STARTTLS, 465 for implicit TLS) |
SMTP_SECURE | none | none, starttls, tls |
SMTP_USER | — | SMTP authentication username (for external relays) |
SMTP_PASS | — | SMTP authentication password |
DKIM_DOMAIN | — | DKIM signing domain (set to enable DKIM signing) |
DKIM_SELECTOR | default | DKIM selector published in DNS |
DKIM_PRIVATE_KEY | — | Inline DKIM private key in PEM format. Prefer DKIM_PRIVATE_KEY_PATH — env vars expose keys to anything that can read the process environment. |
DKIM_PRIVATE_KEY_PATH | — | Path to DKIM private key file (read at startup, never logged) |
Optional
| Variable | Default | Description |
|---|---|---|
SERVER_PORT | 3000 | Portal listen port |
CLUSTER_WORKERS | 2 | Node.js cluster workers (set to 1 to disable) |
LOG_LEVEL | info | trace, debug, info, warn, error |
Advanced
| Variable | Default | Description |
|---|---|---|
DEFAULT_TIMEZONE | UTC | Default timezone for the portal UI and notifications |
ACCESS_TOKEN_TTL | 86400 | Access token lifetime in seconds (default 24h). Opaque tokens stored in Redis. |
REFRESH_TOKEN_TTL | 2592000 | Refresh token lifetime in seconds (default 30d) |
TASK_TIMEOUT_SECONDS | 300 | Max synchronous task wait (5 min default) |
TASK_LOG_RETENTION_DAYS | 30 | Task log retention before automatic cleanup |
AUDIT_LOG_RETENTION_DAYS | 90 | Audit log retention before automatic cleanup |
FILE_TRANSFER_MAX_BYTES | 26214400 | Max file transfer size (default 25 MB) |
File Layout
/opt/managelm-server/
├── nodejs/ # Bundled Node.js runtime
│ └── bin/node
├── portal/ # ManageLM portal application
│ ├── dist/ # Compiled backend (bytecode)
│ ├── web/dist/ # Frontend build
│ ├── node_modules/
│ ├── cluster.cjs # Multi-worker entry point
│ ├── loader.cjs # Bytecode loader
│ ├── .env # Configuration (root:managelm 640)
│ └── ...
└── agent/ # Agent source (served to managed hosts)
├── managelm-agent.py
├── lib/
└── bin/
All files are owned by root:root except .env which is root:<service-user> 640. The service user can read the configuration but cannot modify any binaries.
Upgrading
Download the new release and run it on the same server. The installer detects the existing installation and upgrades in place:
gunzip managelm-<version>-linux-<arch>.sh.gz
sudo bash managelm-<version>-linux-<arch>.sh
The upgrade will:
- Stop the running service
- Replace compiled code, static assets, and the bundled Node.js runtime
- Reinstall production dependencies
- Update the systemd unit file
- Preserve your
.env(never overwritten) - Restart the service
Database migrations run automatically on startup.
Backup & Restore
PostgreSQL holds the durable data and is the only thing you strictly need to back up. Redis is rebuilt from PostgreSQL on portal startup; back it up only if you want to preserve in-flight sessions across restores. Don't forget to copy .env — it holds ENCRYPTION_KEY and is not regenerable.
Backup
# PostgreSQL (run regularly)
pg_dump -U managelm managelm > managelm-backup-$(date +%F).sql
# Configuration (contains ENCRYPTION_KEY — store securely)
cp /opt/managelm-server/portal/.env ./env-backup-$(date +%F)
# Optional — Redis snapshot (in-flight session state)
redis-cli BGSAVE
cp /var/lib/redis/dump.rdb ./redis-backup-$(date +%F).rdb
Restore
# Stop the portal
systemctl stop managelm-server
# Restore PostgreSQL
psql -U managelm managelm < managelm-backup-YYYY-MM-DD.sql
# Restore configuration (if needed)
cp env-backup-YYYY-MM-DD /opt/managelm-server/portal/.env
# Start the portal — Redis state rebuilds automatically (users will need to log in again)
systemctl start managelm-server
Monitoring
# Service status
systemctl status managelm-server
# Health check
curl -s http://localhost:3000/health
# Live logs
journalctl -u managelm-server -f
# Last 100 lines
journalctl -u managelm-server -n 100
Uninstall
# Stop and disable the service
systemctl stop managelm-server
systemctl disable managelm-server
rm /etc/systemd/system/managelm-server.service
systemctl daemon-reload
# Remove the install directory
rm -rf /opt/managelm-server
# (Optional) Remove the service user
userdel managelm
This does not remove PostgreSQL or Redis data. Drop the database manually if no longer needed:
sudo -u postgres dropdb managelm
sudo -u postgres dropuser managelm
Troubleshooting
Portal won't start
- Check logs:
journalctl -u managelm-server -n 50 - Verify
DATABASE_URLandREDIS_URLin.envare correct. - Test PostgreSQL:
psql "$DATABASE_URL" -c "SELECT 1;" - Test Redis:
redis-cli ping - Check for port conflicts:
ss -tlnp | grep 3000
Agents can't connect
- Verify
SERVER_URLin.envis reachable from the agent server. - Check reverse proxy forwards WebSocket headers. See Internal Portal URL.
- Test:
curl -I https://your-hostname/health
Database permission errors
- "permission denied for schema public" — Connect as a superuser and run:
GRANT ALL ON SCHEMA public TO managelm; - "permission denied for relation …" — The user needs ownership or full grants. Re-run:
ALTER DATABASE managelm OWNER TO managelm; - "FATAL: password authentication failed" — Check
DATABASE_URLcredentials match the PostgreSQL user. Test withpsqlfirst. - "could not connect to server: Connection refused" — Verify
listen_addressesinpostgresql.confand checkpg_hba.confallows the portal host. - Managed PostgreSQL (AWS RDS, GCP Cloud SQL, etc.) — The managed user is usually not a superuser. Grant schema access explicitly and ensure the security group / firewall allows the portal.
Email not sending
- Without
SMTP_HOST, email is disabled. Set it to your SMTP server. - Check logs:
journalctl -u managelm-server | grep -i mail
.env permission denied
- The
.envfile isroot:managelm 640. Edit withsudo. - After editing, verify permissions:
ls -la /opt/managelm-server/portal/.env