Self-Hosted Docker Installation Guide

Deploy ManageLM on your own infrastructure with Docker.

Image variant:

Overview

What this guide sets up. An internal-only ManageLM portal with a separate public MCP endpoint for Claude.ai. The portal (admin UI, agent enrollment, OAuth login) stays behind your reverse proxy on an internal hostname; a dedicated public subdomain reverse-proxies only the curated MCP/OAuth surface so Claude.ai can reach the connector. This is the recommended shape for self-hosted production.

ManageLM is published as managelm/portal on Docker Hub with two variants:

ImageIncludesBest for
managelm/portal:latestPortal onlyProduction — bring your own PostgreSQL & Redis
managelm/portal:allinonePortal + PostgreSQL + RedisQuick evaluation, testing

ManageLM Only (Recommended)

The ManageLM Only image (managelm/portal:latest) contains only the ManageLM application. You provide your own PostgreSQL and Redis instances — either as separate Docker containers (included in the default compose file) or as external/managed services. This gives you full control over database configuration, backups, scaling, and upgrades independently of the portal.

All-in-One (with DB)

The All-in-One (with DB) image (managelm/portal:allinone) bundles the portal, PostgreSQL, and Redis into a single container. It is the fastest way to try ManageLM — one container, no external dependencies — but it is not recommended for production because database and cache lifecycle are tied to the container, making independent scaling, backups, and upgrades harder.

Prefer a native install? See the Self-Hosted Package Installation Guide. Don't want to self-host? Use the managed SaaS at app.managelm.com.
No limits on monitors, certificates, or backups. The per-account limits shown in the Portal Documentation (Free / Pro / Business tiers) apply only to the managed SaaS. On self-hosted installs these three resources are unlimited — create as many monitors, certificates, and backups as your hardware supports.

Requirements

Quick Start

Single container — no external dependencies. Pick whichever style you prefer:

Docker Compose

  1. Download the compose file
    mkdir managelm && cd managelm
    curl -O https://app.managelm.com/doc/docker-compose.allinone.yml
    mv docker-compose.allinone.yml docker-compose.yml
  2. Edit docker-compose.yml — set:
    • SERVER_URL — portal URL (e.g. https://managelm.example.com behind a proxy, or http://192.168.1.10:3000 for testing)
    • PUBLIC_URL — MCP public URL for Claude.ai (e.g. https://mcp.example.com). Required for Claude Custom Connector; leave unset for testing.
    • SMTP_FROM — sender email
  3. Start
    docker compose up -d
Or

docker run

Single command, no compose file needed:

docker run -d \
  --name managelm \
  -p 3000:3000 \
  -e SERVER_URL=http://localhost:3000 \
  -e [email protected] \
  -v managelm_pgdata:/data/postgres \
  -v managelm_redisdata:/data/redis \
  --restart unless-stopped \
  managelm/portal:allinone

For production, add -e PUBLIC_URL=https://mcp.example.com — required for the Claude Custom Connector flow. See MCP Public URL.

Then open your SERVER_URL and register — the first user becomes the account owner.

Want to use an external PostgreSQL? Add -e DATABASE_URL=postgresql://user:pass@host:5432/managelm (or the equivalent environment entry in compose) — this overrides the embedded DB. Same pattern for Redis with REDIS_URL.

Portal + PostgreSQL + Redis as separate containers. Pick whichever style you prefer:

Docker Compose

  1. Download the compose file
    mkdir managelm && cd managelm
    curl -O https://app.managelm.com/doc/docker-compose.yml
  2. Edit docker-compose.yml — set:
    • SERVER_URL — portal URL (e.g. https://managelm.example.com behind a proxy, or http://192.168.1.10:3000 for testing)
    • PUBLIC_URL — MCP public URL for Claude.ai (e.g. https://mcp.example.com). Required for Claude Custom Connector; leave unset for testing.
    • SMTP_FROM — sender email
    • Database password — replace change-me-strong-password in both places it appears (DATABASE_URL + postgres service's POSTGRES_PASSWORD); they must match.
  3. Start
    docker compose up -d
Or

docker run

Start PostgreSQL and Redis first, then the portal:

# PostgreSQL
docker run -d --name managelm-db \
  -e POSTGRES_DB=managelm \
  -e POSTGRES_USER=managelm \
  -e POSTGRES_PASSWORD=your-db-password \
  -v managelm_pgdata:/var/lib/postgresql/data \
  --restart unless-stopped \
  postgres:16-alpine

# Redis
docker run -d --name managelm-redis \
  -v managelm_redisdata:/data \
  --restart unless-stopped \
  redis:7-alpine redis-server --appendonly yes

# Portal
docker run -d --name managelm \
  -p 3000:3000 \
  -e SERVER_URL=http://localhost:3000 \
  -e [email protected] \
  -e DATABASE_URL=postgresql://managelm:your-db-password@managelm-db:5432/managelm \
  -e REDIS_URL=redis://managelm-redis:6379 \
  --link managelm-db --link managelm-redis \
  --restart unless-stopped \
  managelm/portal:latest

Then open your SERVER_URL and register — the first user becomes the account owner.

Using your own PostgreSQL / Redis? See External Database Setup below for the full procedure (creating the user, granting schema access, configuring SSL).

External Database Setup

When using your own PostgreSQL or Redis instead of the containers from the compose file, follow these steps to prepare them before starting the portal.

PostgreSQL

ManageLM requires PostgreSQL 15+ (16 recommended). The portal runs migrations automatically on startup and needs full control over its schema.

  1. Create the database and user
    # Connect as a PostgreSQL superuser (e.g. postgres)
    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;
    
    -- If on PostgreSQL 15+, also grant CREATE (changed default in PG15)
    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
  2. Allow remote connections (if PostgreSQL is on a different host)
    # In postgresql.conf — listen on all interfaces
    listen_addresses = '*'
    
    # In pg_hba.conf — allow the portal host (replace with your subnet)
    host  managelm  managelm  10.0.0.0/8  scram-sha-256

    Restart PostgreSQL after editing: systemctl restart postgresql

  3. Set DATABASE_URL in your docker-compose.yml
    DATABASE_URL=postgresql://managelm:your-strong-password@db-host:5432/managelm
  4. Test the connection (from the portal host)
    psql "postgresql://managelm:your-strong-password@db-host:5432/managelm" -c "SELECT 1;"
SSL/TLS: For encrypted connections, set DB_SSL=require in your docker-compose.yml. To verify the server certificate, use DB_SSL=verify-ca with DB_SSL_CA=/path/to/ca.pem (mount the CA file into the container).
PostgreSQL 15+ changed defaults. In PG 15 the CREATE privilege on the public schema was revoked for non-owners. If the portal fails to start with "permission denied for schema public", run: GRANT ALL ON SCHEMA public TO managelm;

Redis

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.

  1. Enable persistence (recommended)
    # In redis.conf
    appendonly yes
    appendfsync everysec
  2. Set REDIS_URL in your docker-compose.yml
    # Without authentication
    REDIS_URL=redis://redis-host:6379
    
    # With authentication (Redis 6+ ACL)
    REDIS_URL=redis://username:password@redis-host:6379
  3. Test the connection
    redis-cli -h redis-host ping
    # Expected: PONG
Redis TLS: Set REDIS_TLS=on in your docker-compose.yml for encrypted connections. Use a rediss:// URL scheme.

Remove compose services

When using external databases, remove the postgres and redis services, their volumes, and the depends_on block from your docker-compose.yml. A minimal compose file looks like:

services:
  portal:
    image: managelm/portal:latest
    ports:
      - "3000:3000"
    environment:
      - SERVER_URL=http://localhost:3000
      - [email protected]
      - DATABASE_URL=postgresql://user:pass@db-host:5432/managelm
      - REDIS_URL=redis://redis-host:6379
    restart: unless-stopped

First Steps After Install

Once the portal is running:

  1. Front the portal with TLS — reverse proxy at SERVER_URL (nginx, Apache, or Traefik). Required before agents can enroll.
  2. Set up the MCP public URL — dedicated subdomain (e.g. mcp.example.com) reverse-proxied to portal /public/. Required for Claude.ai Custom Connector.
  3. Register your account at your SERVER_URL.
  4. Configure the LLM — Local (Ollama), Cloud, or Proxied access mode.
  5. Import skills from the built-in catalog.
  6. Install an agent on your first server.
  7. Connect Claude via MCP.
Double-check SERVER_URL before registering. Agents derive their WebSocket URL from it, so it must be the exact address reachable from agent machines. See the SERVER_URL reference for proxy / no-proxy guidance.
For detailed instructions on all portal features, see the Portal Documentation.

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.

Set TRUSTED_PROXIES when fronting the portal. The proxy sets X-Forwarded-For with the real client IP, but Fastify 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 Docker container; use the proxy's IP/CIDR otherwise. See Environment Variables for details.
Agents require a trusted TLS chain. They verify the portal's certificate against the agent host's system trust store and reject anything they can't verify — this includes self-signed certificates and certificates issued by a private CA the host doesn't trust. The same applies on private networks: a LAN-only deployment still needs a certificate the agent can validate.
  • 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>

Traefik

Traefik discovers the portal from Docker labels — no separate config file. WebSocket upgrades are handled transparently. Add these labels to the portal service in your docker-compose.yml:

    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.managelm.rule=Host(`managelm.example.com`)"
      - "traefik.http.routers.managelm.entrypoints=websecure"
      - "traefik.http.routers.managelm.tls.certresolver=letsencrypt"
      - "traefik.http.services.managelm.loadbalancer.server.port=3000"

Replace letsencrypt with the cert resolver name on your Traefik instance, and remove the ports: mapping from the portal service — with Traefik, the portal is only reached through the proxy. If you don't have Traefik running yet, see the Traefik quick-start for setting up an instance with cert resolvers.

Agents stuck connecting? Check that your proxy forwards the Upgrade and Connection headers for WebSocket (Traefik does this automatically; nginx and Apache need the config above).

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://<portal>:3000/public/. Single rule, allowlist enforced by the portal.

Requirements

Set PUBLIC_URL

In your docker-compose.yml (or as a -e on docker run):

PUBLIC_URL=https://mcp.example.com

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
Why a separate hostname? The well-known OAuth discovery URL (RFC 8414) must live at the root of the issuer URL. Sharing with the portal hostname would either expose admin endpoints publicly or require a complex path-stripping setup. A dedicated subdomain is the simplest and safest pattern.
The customer reverse proxy must NOT expose /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).

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.

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.

Reverse-proxy support: the WebSocket connection to /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

Environment Variables

Required

VariableDescription
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 container 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 container 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.
SMTP_FROMSender email address. Validated at startup but only effective when SMTP_HOST is also set; otherwise no emails are sent.
POSTGRES_PASSWORDPostgreSQL password (multi-container only — embedded DB in the all-in-one uses fixed localhost-only credentials)

Strongly recommended

The all-in-one image auto-generates an ENCRYPTION_KEY on first run and persists it to /data/encryption.key. You only need to set it explicitly if you want to manage the key yourself (e.g. to restore a backup on a new host).

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.

VariableDefaultDescription
ENCRYPTION_KEYAES-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 the proxy on the same host as the Docker container, 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

The all-in-one image auto-configures these to the embedded services. Override only if you want to point at an external PostgreSQL or Redis.

The shipped compose file sets these to the bundled postgres and redis services. Override to use external instances.

VariableDefaultDescription
DATABASE_URLset by composePostgreSQL connection string. Override for external DB.
REDIS_URLset by composeRedis connection string. Override for external Redis.
DB_SSLnoneSSL mode (none, require, verify, verify-ca)
DB_SSL_CAPath to CA certificate file (used with DB_SSL=verify-ca)
DB_POOL_MAX20Maximum PostgreSQL connections per portal worker
REDIS_TLSautoRedis TLS (auto = on for rediss:///valkeys://, on = force, off = skip)
REDIS_DB0Logical database number (0–15). Useful when sharing a Redis instance.

SMTP & DKIM

VariableDefaultDescription
SMTP_HOSTSMTP server hostname. When empty, email is disabled (no connection attempts).
SMTP_PORT25SMTP port (587 for STARTTLS, 465 for implicit TLS)
SMTP_SECUREnonenone, starttls, tls
SMTP_USERSMTP authentication username (for external relays)
SMTP_PASSSMTP authentication password
DKIM_DOMAINDKIM signing domain (set to enable DKIM signing)
DKIM_SELECTORdefaultDKIM selector published in DNS
DKIM_PRIVATE_KEYInline 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_PATHPath to DKIM private key file inside the container (read at startup, never logged)

Optional

VariableDefaultDescription
SERVER_PORT3000Portal listen port
CLUSTER_WORKERS2Node.js cluster workers (set to 1 to disable)
LOG_LEVELinfotrace, debug, info, warn, error

Advanced

VariableDefaultDescription
DEFAULT_TIMEZONEUTCDefault timezone for the portal UI and notifications
ACCESS_TOKEN_TTL86400Access token lifetime in seconds (default 24h). Opaque tokens stored in Redis.
REFRESH_TOKEN_TTL2592000Refresh token lifetime in seconds (default 30d)
TASK_TIMEOUT_SECONDS300Max synchronous task wait (5 min default)
TASK_LOG_RETENTION_DAYS30Task log retention before automatic cleanup
AUDIT_LOG_RETENTION_DAYS90Audit log retention before automatic cleanup
FILE_TRANSFER_MAX_BYTES26214400Max file transfer size (default 25 MB)

Updating

docker compose pull
docker compose up -d

Migrations run automatically on startup. Back up first for major updates.

Backup & Restore

# Backup
docker compose stop
docker run --rm \
  -v managelm_pgdata:/data/postgres:ro \
  -v managelm_redisdata:/data/redis:ro \
  -v $(pwd):/backup \
  alpine tar czf /backup/managelm-backup-$(date +%F).tar.gz -C / data
docker compose start

# Restore
docker compose down
docker run --rm \
  -v managelm_pgdata:/data/postgres \
  -v managelm_redisdata:/data/redis \
  -v $(pwd):/backup \
  alpine tar xzf /backup/managelm-backup-YYYY-MM-DD.tar.gz -C /
docker compose up -d

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.

# Backup PostgreSQL (run regularly)
docker compose exec postgres pg_dump -U managelm managelm > backup-$(date +%F).sql

# Restore PostgreSQL
docker compose exec -T postgres psql -U managelm managelm < backup-YYYY-MM-DD.sql

# Optional — Redis snapshot (in-flight session state)
docker compose exec redis redis-cli BGSAVE
docker cp $(docker compose ps -q redis):/data/dump.rdb ./redis-$(date +%F).rdb

Monitoring

# Health check
curl -s http://localhost:3000/health

# Logs
docker compose logs -f portal

# Last 100 lines
docker compose logs --tail 100 portal

Troubleshooting

Portal won't start

  • Check the logs first: docker compose logs portal
  • Check for port conflicts on 3000 (or whatever SERVER_PORT you set).
  • Verify DATABASE_URL and REDIS_URL are reachable from the portal container.
  • Run docker compose pspostgres and redis must show "healthy" before the portal will start.

Agents can't connect

  • Verify SERVER_URL is 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_URL credentials match the PostgreSQL user. Test with psql first.
  • "could not connect to server: Connection refused" — Verify listen_addresses in postgresql.conf and check pg_hba.conf allows 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 sending is disabled. Set it to your SMTP server to enable emails.
  • Check SMTP_FROM in your docker-compose.yml — it must be set.
  • For an SMTP relay, verify SMTP_HOST, SMTP_PORT, and credentials.
  • Check logs: docker compose logs | grep -i mail