Home · Install

Install LapseIQ

Self-host LapseIQ on your own infrastructure in about thirty minutes. Docker on a VM you control, TLS via Caddy, backups + AI configuration through a guided setup wizard. Two paths below — fast track if you've shipped Docker before, full walkthrough if any of "VM, SSH, Docker, TLS" reads as a stack of unknowns.

LapseIQ Installation Guide

LapseIQ is self-hosted software. Your contract data never leaves your infrastructure — there's no LapseIQ-operated cloud, no telemetry, no phone-home. ForgeRift LLC publishes the images and the installer; everything else runs on a server you control.

This guide takes you from "nothing yet" to a production instance — TLS, backups, and your team logging in — in roughly 30 minutes. No prior Linux experience is assumed; every command shows its expected output so you know whether it worked. Experienced operators can skip the hand-holding and jump straight to the one-line install.

Whichever path you take, you end up in the same place: a working LapseIQ instance at https://lapseiq.example.com (or whatever domain you choose), with your team's logins working and contracts ready to import.


How this guide is organized

Three parts. Most operators only need the first.

Get it running — pick the path that matches your experience:

Reference — look these up when a step points you at them:

Operate — running, maintaining, and fixing your instance:


Part 1: What you need before you start

A Linux server with internet access

LapseIQ runs as a Docker container on Linux. Host it on hardware you already own — a spare box, an on-prem VM, or a VPS you already run — so your data never leaves your own infrastructure. If you'd rather spin up a fresh cloud instance, any provider works; the ones below have one-click "Ubuntu 22 with Docker" templates so you can be SSH'd in within five minutes.

Provider Tier we'd pick
DigitalOcean "Basic Droplet, Regular, 1 GB / 1 vCPU / 25 GB SSD"
Linode "Nanode 1 GB"
Hetzner "CX22" (EU only)
AWS EC2 t4g.small with 20 GB gp3 EBS

The first three offer one-click Ubuntu 22 images. Whichever you pick:

After provisioning, you'll have an IP address (e.g. 206.189.200.29) and a root password or SSH key. Save both.

A domain name pointing at the server

LapseIQ is meant to be reached at a real domain like lapseiq.example.com — that's how TLS certificates work, and how your team will get used to logging in. Two parts to this:

1. Buy a domain (or use one you already own). Any registrar works:

If you're hosting this for an existing company, you almost certainly already own the domain. You'll add a subdomain (lapseiq.yourcompany.com).

2. Point that domain at your server. In your registrar's DNS panel, create an A record that maps your subdomain to the IP you got from the cloud provider above:

Type:   A
Name:   lapseiq           (or whatever subdomain you want)
Value:  206.189.200.29    (your server's IP)
TTL:    300 (or "Automatic")

Save the change. DNS propagates within ~5 minutes for most registrars; some take up to an hour. You can verify by running nslookup lapseiq.yourcompany.com on your local machine — when it returns the right IP, you're ready.

About 30 minutes to set up

That's it. If you host on hardware you already run, there's nothing more to buy. If you use a cloud VM, you'll pay your provider their usual rate for a small instance plus a domain — LapseIQ itself has no subscription and no per-seat fees. The optional renewal-brief feature uses your own AI provider key, billed by that provider only when you use it.


Part 2: Step-by-step walkthrough

If you've never used SSH or Docker, follow this section line by line. Every command shows the expected output so you'll know whether it worked. If something doesn't match, jump to Troubleshooting.

Step 1: Connect to your server

From your laptop's terminal (macOS: Terminal.app; Windows: PowerShell or Windows Terminal; Linux: any terminal):

ssh root@206.189.200.29

Replace the IP with yours. The first connection will ask:

The authenticity of host '206.189.200.29 (206.189.200.29)' can't be established.
ED25519 key fingerprint is SHA256:abc...
Are you sure you want to continue connecting (yes/no/[fingerprint])?

Type yes and press Enter. Then enter the root password your cloud provider gave you (or, if you set up an SSH key during provisioning, it'll connect without a password). You'll see something like:

Welcome to Ubuntu 22.04 LTS (GNU/Linux 5.15.0-x86_64)
root@lapseiq:~#

That # prompt means you're on the server. Everything from here runs there, not on your laptop.

Step 2: Run the installer

Copy and paste this single line:

curl -fsSLO https://lapseiq.com/install.sh && less install.sh

This downloads the installer and pages through it so you can read what it's about to do before running anything. Press q to quit less when you've read enough. Then run:

bash install.sh

The installer will:

  1. Show the EULA and ask you to type yes to accept. The full text lives at https://lapseiq.com/eula.
  2. Check for Docker. If Docker isn't installed, the installer offers to install it via Docker's official convenience script. Say yes; it takes about a minute.
  3. Ask you three questions:
    • "Public domain" → enter https://lapseiq.yourcompany.com (the domain you set up in Part 1).
    • "Admin email address" → your email. Used by the setup wizard.
    • "Brevo API key for transactional email" → press Enter to skip for now. You can configure email later (Part 4).
  4. Generate the secrets (POSTGRES_PASSWORD, JWT_SECRET, MASTER_KEY) and write them to .env.
  5. Pull the LapseIQ images from GitHub Container Registry.
  6. Start the stack (database + server + client).
  7. Wait for the API to respond, then print the setup-wizard URL.

When it finishes, you'll see:

✓ API healthy at http://localhost:3001/api/health

LapseIQ is installed.

Next step: open the setup wizard and create your admin account:

  https://lapseiq.yourcompany.com/setup

Operational hints:
  - Logs:           docker compose logs -f server
  - Stop:           docker compose down
  - Update images:  docker compose pull && docker compose up -d
  - Backups:        nightly pg_dump.gz lands in ./backups
  - .env location:  /root/lapseiq/.env  (mode 600 — back this up off-box)

Step 3: Back up your .env RIGHT NOW

Don't skip this step. Do it before you do anything else with the installed instance.

The installer just generated three secrets. One of them — MASTER_KEY — encrypts the API keys and 2FA secrets stored in your database. If you ever lose MASTER_KEY, that data is gone forever. Not "we can recover it with a support ticket" — gone. The encryption is genuine AES-256-GCM; there is no backdoor.

Open the .env file and read the values:

cat /root/lapseiq/.env

Copy the entire contents into your team's password manager (1Password, Bitwarden, etc.) under a note titled "LapseIQ — production .env". You're specifically protecting MASTER_KEY, but back up the whole file because losing JWT_SECRET would log out every user and losing POSTGRES_PASSWORD would lock you out of the database.

Important: back up the .env somewhere DIFFERENT from where your database backups go. If both end up in the same place and that place fails, you've lost both halves.

Step 4: Set up TLS (HTTPS) with Caddy

Right now your install is running on HTTP only and is not safe for production use — passwords would travel across the network in cleartext. The fix is a reverse proxy that terminates TLS. We recommend Caddy because it auto-renews Let's Encrypt certificates for free with zero config.

Install Caddy:

apt update
apt install -y caddy

Create a Caddyfile that proxies traffic to LapseIQ:

cat > /etc/caddy/Caddyfile <<'EOF'
lapseiq.yourcompany.com {
    reverse_proxy localhost:3001
    encode gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Frame-Options "DENY"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
    }
}
EOF

Replace lapseiq.yourcompany.com with your actual domain. Then:

systemctl reload caddy

Caddy will contact Let's Encrypt, prove ownership of your domain (using the DNS record you set up in Part 1), and obtain a TLS certificate. This takes about 30 seconds on first run. If it fails, see Troubleshooting → Caddy / TLS issues.

Now update your LapseIQ install to know it's behind a proxy. Edit .env:

nano /root/lapseiq/.env

Find the line TRUST_PROXY=true and confirm it's there (install.sh wrote it). For a Cloudflare-fronted setup, replace it with the CIDR allowlist form — see TLS termination in the reference section.

Restart LapseIQ so it picks up the change:

cd /root/lapseiq
docker compose up -d

Open https://lapseiq.yourcompany.com in your browser. You should see a green padlock and the LapseIQ login screen.

Step 5: Run the setup wizard

The first visit lands on /setup. Walk through the four steps:

  1. Account info — your company name (purely cosmetic, shows in the UI).
  2. Admin account — your name, the email you provided to install.sh, and a password. This is your initial admin user.
  3. Email — choose "Skip for now" or paste a Brevo API key (see Optional environment → Email in the reference section if you want to set this up now).
  4. AI — choose "Skip for now", paste a Cloudflare Workers AI key (recommended for new operators — generous free tier, no credit-card account needed), or pick a different provider from the dropdown and paste its key. Skipping leaves LapseIQ as a pure manual-entry tool with no LLM calls. You can change provider or rotate keys later from Settings -> AI & Extraction.

After you submit, you're logged in as the admin and you can start creating contracts, inviting team members, and importing your first CSV.

Step 6: Run the pre-launch smoke test

If you configured an AI provider in Step 5, run the smoke test once to confirm every AI integration works against your live key. From your SSH session:

cd /root/lapseiq
docker compose exec server node scripts/ai-smoke-test.js

The script exercises all four AI integrations (PDF ingest, signature extraction, renewal brief, news scanner) and prints PASS / FAIL per integration. Any FAIL is a launch blocker. It makes a handful of small AI calls billed by your own provider. Exits with code 0 on full pass, 1 on any failure, 2 if no API key is configured.

If you skipped AI in Step 5, skip this step too — there's nothing to test.

Step 7: Set up the first backup

Backups already run nightly to ./backups at 02:00 server time. Verify that path is writeable and confirm a manual backup right now:

cd /root/lapseiq
docker compose exec db pg_dump -U lapseiq lapseiq | gzip > backups/pre-launch.sql.gz
ls -lh backups/

You should see your pre-launch.sql.gz listed. Now copy that file off the server (S3, your laptop, anywhere except the same VM):

# From your LAPTOP, not the server:
scp root@206.189.200.29:/root/lapseiq/backups/pre-launch.sql.gz ./

If you want backups to ship automatically to S3, see Optional environment → Backups in the reference section.

You're done. Invite your team via Settings → Users, import contracts via the CSV import button on the Contracts page, and you're running.


One-line install

For readers who already have a Linux box, a domain, and Docker familiarity. This collapses Part 2 to a single command:

curl -fsSLO https://lapseiq.com/install.sh
less install.sh                     # read what it's about to do
bash install.sh

The two-step curl + less + bash pattern is recommended over a piped curl … | bash: same end result, but you get to read the script before running it. If you've already inspected the script and trust this project, the one-liner is:

curl -fsSL https://lapseiq.com/install.sh | bash

Either way, the script:

After install, finish with the TLS + setup wizard + smoke test + backup steps from Part 2.

Non-interactive install (CI / IaC)

The installer requires interactive EULA acceptance by default. To bypass for automation:

LAPSEIQ_ACCEPT_EULA=1 bash install.sh
# or
bash install.sh --yes

Either path is logged to .lapseiq-eula-accepted in the install directory. Both are an affirmation of the EULA at https://lapseiq.com/eula.

Windows operators

If you run Docker Desktop on Windows, use the PowerShell installer instead of install.sh. Same prompts, same end state, just pwsh-native:

iwr -useb https://lapseiq.com/install.ps1 -OutFile install.ps1
notepad install.ps1                       # read what it's about to do
pwsh -ExecutionPolicy Bypass -File .\install.ps1

Or, if you trust this project:

iex (iwr -useb https://lapseiq.com/install.ps1).Content

The PowerShell installer mirrors install.sh's behaviour: detects Docker Desktop, prompts for domain / admin email / optional Brevo API key, generates secrets via .NET's RandomNumberGenerator, writes .env, pulls the GHCR images, brings the stack up, polls /api/health, and prints the setup-wizard URL. The same LAPSEIQ_ACCEPT_EULA=1 / -Yes non-interactive flags are supported.

Verifying installer integrity

Each installer ships with a companion .sha256 file. Before running either script, verify it matches:

# bash / Linux / macOS
curl -fsSLO https://lapseiq.com/install.sh
curl -fsSLO https://lapseiq.com/install.sh.sha256
sha256sum -c install.sh.sha256
bash install.sh
# PowerShell / Windows
iwr -useb https://lapseiq.com/install.ps1     -OutFile install.ps1
iwr -useb https://lapseiq.com/install.ps1.sha256 -OutFile install.ps1.sha256
$expected = ((Get-Content install.ps1.sha256) -split '\s+')[0]
$actual   = (Get-FileHash install.ps1 -Algorithm SHA256).Hash.ToLower()
if ($expected -ne $actual) { throw "checksum MISMATCH" } else { "checksum OK" }
pwsh -ExecutionPolicy Bypass -File .\install.ps1

The checksums are committed in the repo alongside the scripts and regenerated via scripts/generate-checksums.sh whenever the scripts change. A mismatch means either the script was tampered with in transit or you have a stale .sha256 file — re-download both.


Manual install with published images

If you'd rather run the stack by hand instead of the one-line installer - usefulnwhen you want to pin a specific version or wire LapseIQ into your own Composenfile - pull the published images:nnbashn# Download the published Compose file from lapseiq.comncurl -fsSLO https://lapseiq.com/docker-compose.ghcr.ymln# Create a .env next to it with the required values (see "Required environment" below)ndocker compose -f docker-compose.ghcr.yml up -dn

The images pull in about a minute. When docker compose ps shows all three services (db, server, client) as running / healthy, visit your server's URL — the first visit lands on /setup.


Required environment

Every value below must be present in .env before the first docker compose up. The compose file uses ${VAR:?...} guards on the must-set secrets, so the stack refuses to start on a missing value rather than silently picking a weak default.

Variable Purpose
DATABASE_URL Postgres connection string. When using the bundled db service this is built from POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB.
POSTGRES_PASSWORD Bundled-Postgres credential. Generate a long random value — there is no recovery path if a public-internet Postgres is brute-forced.
POSTGRES_USER Defaults to lapseiq. Override only if you need to.
POSTGRES_DB Defaults to lapseiq.
JWT_SECRET Signs JWT access tokens. Must be ≥ 32 characters. The startup check refuses common defaults (changeme, secret, etc.).
MASTER_KEY Encrypts API keys, TOTP secrets, and (when ENCRYPT_DOCS=true) document content at rest. Critical — see MASTER_KEY backup below.
CLIENT_URL The public URL the SPA is served from. Required in production. Used for CORS origin and email link bases.

Every other value in server/.env.example is optional or has a sane default. Generate the secret values with:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"     # MASTER_KEY
node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"     # JWT_SECRET
node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"  # POSTGRES_PASSWORD

If you ran install.sh, these were generated for you and live in .env already.


MASTER_KEY backup

The most important paragraph in this document.

MASTER_KEY is the root of all symmetric encryption inside LapseIQ. It encrypts:

If you lose MASTER_KEY, none of that data is recoverable. Not by ForgeRift LLC, not by any tool — the encryption is honest AES-256-GCM with HKDF-derived per-record keys. There is no key escrow.

Two non-negotiables:

  1. Back it up. Put the value in your team password manager (1Password, Bitwarden, AWS Secrets Manager, Hashicorp Vault — pick one). Do this before the first docker compose up. If you ran install.sh, do it the moment the installer finishes.

  2. Back it up somewhere different from your database backup. If a single storage location loses both the encrypted DB and the key, you have lost everything. Standard practice is "DB backups go to S3 bucket A, MASTER_KEY lives in your password manager separately."

MASTER_KEY is rotated by re-encrypting in place, which is currently a manual operation — plan to keep the same value for the lifetime of the instance unless you have a specific compromise event.


Volume mounts

LapseIQ keeps three categories of state outside the application image so they survive image rebuilds and container recreations. The default docker-compose.yml wires these up; if you customize compose, preserve all three.

Mount Purpose
postgres_data (named volume) The database itself. Backed by Docker on the host filesystem.
./uploads (bind mount) Local document storage when STORAGE_DEST=local (default). When STORAGE_DEST=s3, this directory is unused.
./backups (bind mount) Nightly pg_dump output when BACKUP_DEST=local (default).

The bind-mounted directories live next to your compose file. On a fresh clone they are created automatically when compose first starts.


TLS termination

LapseIQ does not terminate TLS itself. You must front the app with a reverse proxy that does — running it on plain HTTP outside of localhost is not safe.

The recommended setup is Caddy (auto-renews Let's Encrypt certificates with zero config). Minimal Caddyfile:

lapseiq.example.com {
    reverse_proxy localhost:3001
}

A hardened production Caddyfile with security headers is shown in Step 4.

nginx works equally well. A worked nginx example with the right headers for LapseIQ lives in docs/examples/nginx in the repo.

If you put a reverse proxy in front of LapseIQ, set TRUST_PROXY in .env so Express resolves the real client IP from X-Forwarded-For. Without this, every audit-log row records the proxy's IP and per-IP rate limits collapse to a single bucket.

Three forms are supported, in order of preference:

A. CIDR allowlist (recommended for production). Comma-separated list of networks Express trusts as legitimate proxy hops. Forged X-Forwarded-For entries from outside the allowlist are structurally ignored, blocking IP spoofing through a proxy chain.

For Cloudflare-fronted deployments, run the helper:

bash scripts/get-trust-proxy-cidrs.sh

It prints a paste-ready single line containing localhost, the standard Docker bridge subnet, and the canonical Cloudflare v4+v6 ranges. Paste the output into .env:

TRUST_PROXY=127.0.0.1,172.16.0.0/12,173.245.48.0/20,...,2400:cb00::/32,...

Re-run the helper periodically (cron weekly is fine) and update .env when Cloudflare expands their ranges. The exact ranges have been stable for years.

B. Legacy boolean. TRUST_PROXY=true is kept for backward compat. It makes Express trust ONE upstream hop. Acceptable when your firewall absolutely guarantees nothing can reach the reverse proxy bypassing the front end (e.g. the proxy is on the same host as the app and only that host has port 80/443 open).

C. Multi-hop integer. For a chain like CloudFront → ALB → server, TRUST_PROXY=2 tells Express to trust two upstream hops. Pin a specific number rather than enabling broad trust.

Security caveat. Never enable TRUST_PROXY=true on a directly- internet-exposed instance. Any client can spoof X-Forwarded-For headers on a server they reach without a trusted proxy in between, which would let them bypass per-IP rate limits and inject fake IPs into your audit log.


Optional environment

Only set the values you actually need. Everything below is opt-in.

Email (Brevo)

Required in production for password reset emails, user invites, and (if you turn it on) feedback submissions. Set EMAIL_MOCK=true to log emails to stdout instead of sending — useful for local development.

For real email, sign up at https://brevo.com, verify your sending domain (SPF + DKIM records, takes ~30 minutes including DNS propagation), generate an API key from Brevo's dashboard (Settings → SMTP & API → API Keys), and set:

BREVO_API_KEY=xkeysib-xxxxxxxxxxxxxxxxxxxxxxxx-yyyyyyyyyyyyyyyyyyyyyyyyyy
EMAIL_FROM="LapseIQ <noreply@yourdomain.com>"
SUPPORT_EMAIL=you@yourdomain.com

Older versions of LapseIQ used Resend; the env var name and key format are different. If you set RESEND_API_KEY instead of BREVO_API_KEY, the server logs a one-time deprecation warning at startup and email sending is skipped. Rename to BREVO_API_KEY and use a Brevo key.

AI provider + fallback cascade

Required for contract field extraction and renewal-brief generation. Set AI_ENABLED=false to run LapseIQ as a pure manual-entry tool with no LLM calls. To enable AI, pick a primary provider:

AI_ENABLED=true
AI_PROVIDER=cloudflare           # primary (default + recommended)
CF_WORKERS_AI_API_KEY=...        # Cloudflare account API token

Supported primary providers: cloudflare (default), anthropic, openai, azure_openai, gemini. Each provider has its own credential env var: CF_WORKERS_AI_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY, GEMINI_API_KEY. The legacy generic AI_API_KEY is still honored for Anthropic-only deployments.

Fallback cascade. When the primary fails (rate limit, quota exhausted, network blip, model deprecation), LapseIQ retries the same request against Hugging Face Inference, then Groq. Each fallback is free-tier-friendly and gated by its own per-action budget so a hostile visitor can't drain quota indefinitely. To enable the fallbacks, supply their keys:

HUGGINGFACE_API_KEY=hf_...
GROQ_API_KEY=gsk_...

The cascade fires automatically — no config flag to flip. If you omit the fallback keys, the cascade simply stops at the primary; an outage of the primary then surfaces as a brief-generation error rather than a transparent recovery.

You can also leave AI_PROVIDER and all provider keys blank and save the credentials inside the app via Settings -> AI & Extraction — they are encrypted with MASTER_KEY before being persisted. DB-stored keys satisfy the same provider-aware startup gate that env-stored keys do.

Web-search enrichment (Tavily, optional)

Set TAVILY_API_KEY to enable Tavily web-search enrichment on the renewal brief's Market section. Per-template domain allowlists keep results bounded to authoritative sources (FCC for telecom, FRED for lease, EIA for utilities, etc.). When the key is omitted, briefs still generate — the no-reference fallback fires and the model leans on its training-time knowledge for the Market section.

# Optional — omit to disable web-search enrichment
TAVILY_API_KEY=tvly-...

The renewal brief is gated by a per-account toggle (Settings → AI → "Enable AI Renewal Brief"), default OFF on self-host. Admins flip it on per account.

AI daily caps

Self-host runs uncapped by default — operators bring their own AI key, credit spend is the operator's concern. On a shared instance (e.g. internal demo, public sandbox), cap per user with:

# Uniform fallback
# AI_DAILY_CAP_PER_USER=

# Per-action overrides
# AI_DAILY_CAP_PER_USER_EXTRACT=         (PDF ingest + signature reading SHARED)
# AI_DAILY_CAP_PER_USER_ASK=             (Ask LapseIQ assistant)
# AI_DAILY_CAP_PER_USER_BRIEF=           (Renewal brief generation)
# AI_DAILY_CAP_PER_USER_BRIEF_SEARCH=    (Tavily web search per brief)

DEMO_MODE=true applies sensible default caps (extract=2, ask=6, brief=1, brief_search=1) without any explicit env override.

S3-compatible storage

Default is local filesystem. Switch to S3 (or Backblaze B2 / Wasabi / MinIO / Cloudflare R2) by setting STORAGE_DEST=s3 plus STORAGE_S3_* credentials:

STORAGE_DEST=s3
STORAGE_S3_BUCKET=lapseiq-uploads-yourcompany
STORAGE_S3_REGION=us-east-1
STORAGE_S3_ENDPOINT=https://s3.amazonaws.com
STORAGE_S3_ACCESS_KEY_ID=AKIA...
STORAGE_S3_SECRET_ACCESS_KEY=...

The presigned-URL TTL defaults to 5 minutes; configurable via STORAGE_S3_URL_TTL_SECONDS=300.

Document encryption

Set ENCRYPT_DOCS=true to encrypt every uploaded document with AES-256-GCM at rest. Requires MASTER_KEY to be set (which it always should be). Existing unencrypted uploads remain readable — only new uploads are encrypted.

Backups

Default is a nightly local pg_dump at 02:00 server time into ./backups. To ship backups to S3 instead, set:

BACKUP_DEST=s3
BACKUP_S3_BUCKET=lapseiq-backups-yourcompany
BACKUP_S3_REGION=us-east-1
BACKUP_S3_ENDPOINT=https://s3.amazonaws.com
BACKUP_S3_ACCESS_KEY_ID=AKIA...
BACKUP_S3_SECRET_ACCESS_KEY=...

BACKUP_DEST=both writes to both targets.

A note on cron scheduling and timezones

LapseIQ's nightly jobs (backups, audit-log prune, demo reset, alert digest, news scan) are scheduled with node-cron against server-local time. On a UTC droplet — the default for DigitalOcean, Hetzner, Linode, and most cloud providers — that means the published times (02:00 backups, 03:00 audit-log prune, 03:30 demo reset, 07:00 alert engine) all fire at the same UTC clock-time around the world.

If your server's timezone is something other than UTC (you set it during provisioning, or your provider defaults to a regional zone), those jobs fire at the equivalent local clock-time. A server set to America/Chicago will run the 02:00 backup at 02:00 CST, not at 02:00 UTC.

Two options:

  1. Recommended: keep the server on UTC. This is the default for essentially every cloud provider, and it makes log timestamps easier to correlate across regions. Verify with timedatectl on Ubuntu/Debian.

  2. Set the timezone explicitly on the server if your team needs logs in local time. The cron times will then fire in that local time. Example: timedatectl set-timezone America/Chicago flips the server clock; LapseIQ picks it up on next restart.

There is currently no env var to pin individual crons to UTC independent of the server clock. If you need that, file an issue — it's a small addition to index.js cron definitions but we haven't shipped it yet.

Audit log retention

ACTIVITY_LOG_RETENTION_DAYS=365 (default) trims the audit log nightly at 03:00. Lower this for high-volume tenants where the audit table is dominating pg_dump time. BACKUP_LOG_RETENTION_DAYS=180 (default) does the same for the backup-history table.

Pre-launch smoke test

Before you open a freshly-configured instance to users — and after every AI provider, model, or SDK change — run:

docker compose exec server node scripts/ai-smoke-test.js

(Or, if you cloned the source: node server/scripts/ai-smoke-test.js from the repo root.)

The script exercises every AI integration — PDF ingest extraction, signature image extraction, renewal-brief generation, and news scanner classification — against your live primary provider (and the fallback cascade if the primary fails) and prints PASS / FAIL per integration. Any FAIL is a launch blocker. It makes a handful of small AI calls, billed by your own provider (free on Cloudflare Workers AI's free tier). Exits 0 on full pass, 1 on any failure, 2 if no API key is configured (so CI can tell "not configured" apart from "broken").

Feedback feature gate

LapseIQ ships with an in-product feedback button that, when enabled, emails submissions to the address in SUPPORT_EMAIL via your Brevo account.

Default behaviour:


Demo mode

DEMO_MODE=true puts the running instance into a constrained sandbox suitable for public sales demos and recorded walkthroughs. When set:

To start a demo instance from scratch:

echo "DEMO_MODE=true" >> .env
docker compose up -d
docker compose exec server node scripts/seed-demo.js

The seed creates four canned accounts:

admin@demo.local      / Admin1234!
manager@demo.local    / Manager1234!
viewer@demo.local     / Viewer1234!
consultant@demo.local / Consultant1234!

Demo mode persists across restarts but resets data nightly. Banner copy at the top of the SPA reminds visitors not to enter real data. Admins can trigger an immediate reset from Settings → Account Data → Reset demo instance.


Upgrades

LapseIQ migrations run automatically on container startup (prisma migrate deploy is part of the server CMD). The standard upgrade path is:

# Always take a backup first
cd /root/lapseiq
docker compose exec db pg_dump -U lapseiq lapseiq | gzip > backups/pre-upgrade-$(date +%F).sql.gz

# If you installed via install.sh / GHCR images:
docker compose pull
docker compose up -d

# If you cloned and build from source:
git pull
docker compose up --build -d

Migrations are forward-only and idempotent — running an upgraded image against an already-current database is a no-op. Prisma's deploy command takes a row-level advisory lock, so two replicas starting simultaneously will not double-run migrations.

Always take a database backup before any upgrade. A manual docker compose exec db pg_dump -U lapseiq lapseiq | gzip > pre-upgrade.sql.gz is enough; the nightly cron also writes one to ./backups automatically if the timing happens to be convenient.


Troubleshooting

Common failures with the literal error you'll see and what to do about each.

Install-time issues

Docker not found. Install Docker Desktop from https://docker.com/... You're on macOS and Docker isn't installed. Install Docker Desktop, start it, and re-run bash install.sh. The installer doesn't auto-install Docker on macOS because Docker Desktop has license terms that should be read.

Need sudo (or root) to install Docker. Re-run as root or install sudo first. You're on Linux as a non-root user without sudo available. Either re-run as root (su - then re-run install.sh) or have your sysadmin install sudo.

Docker Compose v2 plugin not found. You have the old docker-compose script but not the new docker compose v2 subcommand (note the space). On Ubuntu/Debian: apt install docker-compose-plugin. On macOS, Docker Desktop bundles it.

Admin email is required. You pressed Enter at the admin-email prompt without typing anything. Re-run install.sh and provide a valid email — it's used by the setup wizard to identify your initial admin user.

openssl not found. Rare. Install with apt install openssl (Ubuntu/ Debian) or brew install openssl (macOS). The installer uses openssl to generate JWT_SECRET, MASTER_KEY, and POSTGRES_PASSWORD.

API didn't respond at http://localhost:3001/api/health within 30s. The server container didn't come up cleanly. Run docker compose logs server to see what happened. Common causes:

Runtime / 503 / health-check issues

docker compose up exits with POSTGRES_PASSWORD must be set. You have not populated .env yet. Copy from .env.example and fill the required values listed above. If you ran install.sh, .env should already exist — check that you're in the right directory (cd /root/lapseiq).

/api/health returns 503 with needsSetup: true. This is normal on a fresh instance — visit /setup in your browser to create the initial admin. The setup gate releases automatically once the wizard completes.

/api/health returns 503 with error: "Database unreachable.". The Postgres container has not finished initialization, or DATABASE_URL is wrong. Check docker compose logs db. If the DB is stuck, restart it: docker compose restart db. If the issue persists, check that POSTGRES_PASSWORD in .env matches what's encoded in DATABASE_URL — install.sh writes them consistently, but hand-edited .envs sometimes drift.

MASTER_KEY must decode to exactly 32 bytes at startup. Your value is not a valid base64-encoded 32-byte string. Regenerate with the command in the Required environment section. If this is an existing install (not a fresh one), DO NOT regenerate — your data is encrypted with the OLD key and a new key makes it unreadable. Recover the original from your password manager backup. If you have no backup, restore the entire .env from a prior database backup point in time.

JWT_SECRET is too short or JWT_SECRET is a known-weak value. The startup check refuses common defaults (changeme, secret, etc.) and anything shorter than 32 characters. Generate a strong one with the command in Required environment.

Refresh tokens immediately fail with Invalid refresh token. Almost always a sign that the SPA is calling the API across a domain mismatch that breaks the cookie or Authorization header. Verify CLIENT_URL matches the URL in your browser's address bar exactly (scheme, host, and port all included). If you're testing locally on http://localhost:5173, CLIENT_URL must be exactly http://localhost:5173 — not http://127.0.0.1:5173, not https://localhost.

"Too many attempts" on login. The per-IP auth rate limiter kicked in after several failed attempts in a row. Wait 15 minutes, or restart the server container to clear the in-memory state. If you keep tripping this on legitimate logins, you probably need TRUST_PROXY configured (see next item).

Audit log shows the same IP for every event. You enabled TRUST_PROXY without a reverse proxy, OR your reverse proxy is not setting X-Forwarded-For, OR your TRUST_PROXY value doesn't include the proxy's network. For a Cloudflare-fronted setup, run bash scripts/get-trust-proxy-cidrs.sh and paste the output into .env.

Caddy / TLS issues

Failed to obtain certificate: timeout when Caddy first starts up. Almost always a DNS issue: Let's Encrypt can't verify ownership of lapseiq.yourcompany.com because DNS hasn't propagated yet, or your A record points at the wrong IP. Verify with nslookup lapseiq.yourcompany.com from your laptop — it should return your server's IP within ~5 minutes of creating the A record.

Browser shows Your connection is not private even after Caddy started. Caddy might still be obtaining the cert. Wait 30 seconds and refresh. If it persists, journalctl -u caddy -n 50 shows what Caddy is doing.

Cloudflare in front of Caddy → infinite redirect loop. Cloudflare's default "Flexible" SSL mode talks HTTP to your origin, but Caddy is set up for HTTPS-only. Either:

Docker / disk / system issues

no space left on device during docker compose pull. Old images are taking up disk. Clean up: docker system prune -a (this removes unused images, containers, and networks — confirm with y). Be sure you actually want this; running it while LapseIQ is up is safe, but it will re-pull on the next start.

Container restarts in a loop. docker compose ps shows status Restarting. Pull the logs: docker compose logs server for the API, docker compose logs db for Postgres, docker compose logs client for the SPA. The error message at the bottom is what you need.

Server container is up but /api/health times out for minutes. The Prisma migration step on startup is running against a large database that doesn't have prior migrations applied. Wait — prisma migrate deploy is idempotent and takes an advisory lock so two starts won't race. Logs will show Applied migration X per file as it works through them.

Performance issues

Pages load slowly under light load. Likely the bundled Postgres container running on a 1 GB RAM VM is paging to disk. Bump the VM to 2 GB RAM and docker compose up -d. If you have heavy document uploads and many concurrent users, move Postgres to a managed DB (DigitalOcean Managed Database, AWS RDS, etc.) and point DATABASE_URL at it — the bundled db service is fine for the first 100 contracts but isn't tuned for production scale.


Where to go from here