Self-host on a local machine
This guide sets up a single-host self-hosted Chorus: one application container and one
PostgreSQL 17 container, started with Docker Compose from the deploy/self-host/ directory of
the Chorus repository. The install.sh local setup
(Chorus 0.22.2 and later) prepares the image, writes protected configuration, and saves a
wrapper script for later redeployments. You can add a self-signed HTTPS endpoint for your
LAN, or publish the stack through a Cloudflare Named Tunnel that you create yourself.
The full upstream reference is docs/SELF_HOST.md in the repository (Chinese:
docs/SELF_HOST.zh.md). This page condenses it into the steps most installations need.
When to choose this path
Section titled “When to choose this path”| You want… | Use |
|---|---|
| To try Chorus alone, with no Docker | The npm package with embedded PGlite: Get started |
| A long-running instance on your own Linux or macOS host, reachable from your LAN, backed by a real PostgreSQL | This guide |
| Your own Compose files around the published image | Deploy with Docker |
| Several instances, managed PostgreSQL and Redis, or AWS | Deploy in production |
This is a single-instance setup. Redis is deliberately disabled (REDIS_URL is empty), so live
updates use in-memory delivery, which only works with one app container.
Prerequisites
Section titled “Prerequisites”- A Linux host with Docker Engine, or macOS with Docker Desktop running Linux containers.
Docker Compose v2 with support for
up --wait. Check withdocker versionanddocker compose version. - Bash 3.2 or newer, OpenSSL (or LibreSSL), and curl. The optional scripted login check uses
jq. The server does not need Node.js, pnpm, or the AWS CLI. - A checkout of the Chorus repository that contains
deploy/self-host/. - On macOS, start Docker Desktop first and allow it to share the checkout and your private configuration and backup paths.
- Network access to your image registry (and, for Tunnel mode, to Cloudflare).
Install with install.sh
Section titled “Install with install.sh”From the root of the checkout, run the installer and choose the local target:
bash install.sh --target localRunning bash install.sh without --target asks for aws or local first; AWS stays the
default. On a terminal, local setup then asks for:
- Image source:
pull(default) downloads an existing release image,buildcompiles the current checkout with itsDockerfile(productiontarget). - For
pull, the image tag or digest, for examplechorusaidlc/chorus-app:vX.Y.Z. An implicit tag orlatestis rejected. Build mode tags the imagechorus-local:<project>by default (chorus-local:chorus-local). - The ordinary login email and password, and a separate super admin email and password. Password input is hidden and asked twice.
- Also enable self-signed LAN HTTPS? [y/N]. The default is no; see Optional LAN HTTPS.
Both passwords must be a single line of at least 8 bytes; the super admin password may be at
most 72 bytes because of bcrypt. You never generate the bcrypt hash yourself: the installer
pulls the helper image httpd:2.4-alpine and hashes the password with htpasswd (cost 12) in a
throwaway container with no network, passing the password over stdin rather than as an
argument.
Non-interactive install
Section titled “Non-interactive install”For scripted setups, put each password on one line in its own file outside the checkout
(directory mode 0700, file mode 0600). The installer rejects symlinks and files inside the
checkout. Choose one of the two forms:
# Pull an existing release imagebash install.sh --target local --image-source pull \ --image chorusaidlc/chorus-app:vX.Y.Z --project chorus-local \ --email owner@example.com --admin-email admin@example.com \ --password-file /absolute/private/path/login-password \ --admin-password-file /absolute/private/path/admin-password \ --port 18637 --non-interactive# Build the current checkout into chorus-local:chorus-localbash install.sh --target local --image-source build \ --project chorus-local --email owner@example.com --admin-email admin@example.com \ --password-file /absolute/private/path/login-password \ --admin-password-file /absolute/private/path/admin-password \ --port 18637 --non-interactiveAdd --dry-run (and omit both password-file options) to print the plan without hashing,
calling Docker, or writing files. On a non-terminal input the installer never waits for
answers, and missing required values fail. Run bash install.sh --help for the option summary.
| Option | Environment equivalent | Default |
|---|---|---|
--target local | CHORUS_INSTALL_TARGET=local | aws |
--image-source pull|build | CHORUS_IMAGE_SOURCE | pull |
--image | CHORUS_IMAGE | Required for pull; chorus-local:<project> for build |
--project | CHORUS_LOCAL_PROJECT | chorus-local |
--port | CHORUS_PORT | 18637 |
--email, --admin-email | DEFAULT_USER, SUPER_ADMIN_EMAIL | none |
--password-file, --admin-password-file | DEFAULT_PASSWORD, SUPER_ADMIN_PASSWORD | none |
--non-interactive, --dry-run | CHORUS_INSTALL_NONINTERACTIVE=1, CHORUS_INSTALL_DRY_RUN=1 | off |
Command-line options override environment values, and password files take precedence over the password variables. There is no option that takes a raw password on the command line.
What setup creates
Section titled “What setup creates”| Path | Contents |
|---|---|
~/.config/chorus-self-host/<project>/ | New private directory, mode 0700. |
…/<project>/env | Mode 0600. Image references (CHORUS_IMAGE, POSTGRES_IMAGE=postgres:17-alpine), a random POSTGRES_PASSWORD, DEFAULT_USER and the ordinary DEFAULT_PASSWORD in plaintext, CHORUS_PORT, SUPER_ADMIN_EMAIL, and only the bcrypt SUPER_ADMIN_PASSWORD_HASH. Treat it as a secret. |
…/<project>/lan-tls/, …/<project>/lan-https.yml | Only when LAN HTTPS is selected. |
default_deploy_local.sh in the checkout | Mode 0700, secret-free redeploy wrapper. CHORUS_DEPLOY_CONFIG selects another path; its parent directory must exist. |
Setup is for new installations only. If the private directory, the wrapper file, or any Docker container, volume, or network for the project already exists, the installer refuses and changes nothing; it never rotates passwords or reuses a database. A failed startup leaves the new project’s containers and configuration in place for inspection.
NEXTAUTH_SECRET is left empty, so the app generates its signing key in /app/data/.secret
inside the app-data volume. Keep that volume: losing it signs everyone out.
Open Chorus and sign in
Section titled “Open Chorus and sign in”When setup finishes it prints the URLs, the location of the private env file, the redeploy
command, and a project-scoped docker compose … stop command.
- On the host:
http://localhost:18637(or your--port). - From another LAN device:
http://<host-LAN-IP>:18637. Use the host’s real address, not0.0.0.0. - Sign in with the ordinary login you chose. The
/adminpanel uses the separate super admin account.
The built-in email/password login is a bootstrap and demo path, not team SSO. For a team, configure OIDC per company in the admin panel; see Operate a self-hosted Chorus. Plain LAN HTTP does not encrypt credentials or sessions, and browsers withhold the Web Crypto API that the OIDC client needs for PKCE on insecure HTTP origins, so OIDC needs HTTPS.
Redeploy with the saved wrapper
Section titled “Redeploy with the saved wrapper”Use the saved wrapper instead of setup only for local mode when it loads all required
overrides. For tunnel mode or oidc-only.yml/other custom overrides, prepare the desired app
image and use your complete Compose invocation instead; the wrapper would omit those settings.
bash ./default_deploy_local.sh --dry-runbash ./default_deploy_local.shThe wrapper accepts only --dry-run and calls deploy/self-host/deploy-local.sh with the saved
project, private directory, and image source. Each run:
- Rebuilds the current checkout (build mode) or pulls the saved
CHORUS_IMAGE(pull mode). To move a pull-mode instance to a new release, editCHORUS_IMAGEin the private env first. - Pulls the saved
POSTGRES_IMAGEonly if it is missing locally. This is never a database major-version upgrade. - Adds the private overrides
https.yml,pg17.yml, andlan-https.ymlwhen they exist in the private directory, and pulls thelan-httpsimage when that add-on is present. - Runs
docker compose … up -d --pull never --wait. It reuses the same project, volumes, and credentials. It never runsdown, removes volumes, or rotates credentials.
--dry-run validates the saved image references and lists the Compose files it would use,
without calling Docker. Other private overrides (for example an oidc-only.yml) are not
discovered automatically; apply them with an explicit Compose command each time.
Run Compose commands by hand
Section titled “Run Compose commands by hand”For logs, stops, or restarts, use the same project, env file, and Compose files as the wrapper. In Bash:
REPO=/absolute/path/to/ChorusPROJECT=chorus-localENV_FILE="$HOME/.config/chorus-self-host/$PROJECT/env"dc() { docker compose --project-name "$PROJECT" --env-file "$ENV_FILE" \ -f "$REPO/deploy/self-host/compose.yml" \ -f "$REPO/deploy/self-host/compose.local.yml" "$@"}dc psdc logs --tail 100 app dbAppend -f "$HOME/.config/chorus-self-host/$PROJECT/lan-https.yml" (and any other private
override) before "$@" if your installation uses them. Use dc config --quiet to validate,
never an unredacted docker compose config, which prints passwords.
Optional self-signed LAN HTTPS
Section titled “Optional self-signed LAN HTTPS”HTTPS is opt-in and not needed for password login. Enable it during setup by answering yes to the HTTPS prompt and entering the hostname or IPv4 address clients will use, or with options:
bash install.sh --target local --image-source build --https-host 192.0.2.10Replace the documentation address with the host’s real LAN IP or a DNS name that already
resolves to the host. --https-port changes the default port 18638 (it must differ from the
HTTP port). --https turns on the host prompt; non-interactive setups need --https-host or
CHORUS_HTTPS_HOST. --no-https overrides any environment selection. IPv6 literals, wildcard
names, and URLs are rejected.
What it exposes. Setup adds a lan-https Nginx service, started in the same Compose up as
the app and database. It listens on 0.0.0.0:18638 and forwards to http://app:8637 on the
internal Compose network. It marks response cookies Secure, keeps the forwarding and WebSocket
upgrade headers, and disables proxy buffering so Server-Sent Events stream. The plain HTTP
listener stays available, and a Cloudflare Tunnel origin stays internal HTTP. Setup does not
change DNS, router, or firewall rules; restrict both ports to trusted networks.
Where the TLS material lives. Setup generates a 30-day RSA self-signed certificate whose SAN
contains the chosen host, stored as lan-tls/server.crt and lan-tls/server.key in the private
directory. Their paths and the HTTPS port are saved in the private env (CHORUS_TLS_CERT_FILE,
CHORUS_TLS_KEY_FILE, CHORUS_HTTPS_PORT), and compose.lan-https.yml is copied to the private
lan-https.yml. The files are mounted read-only; a missing file fails startup. Redeploys reuse
them and never regenerate the certificate.
For OIDC over this endpoint, register the exact callback (for example
https://<host-LAN-IP>:18638/login/callback) with your identity provider and keep existing
callbacks. Cookies are scoped by host, not port, so HTTP and HTTPS on the same host share
sessions; use HTTPS consistently once logged in.
Renewing. Before the 30-day expiry, or after the host or IP changes, back up the TLS files,
write a new certificate and key with the matching SAN to the same paths, then recreate only the
proxy with your full Compose command: up -d --no-deps --force-recreate lan-https. A plain
Nginx restart does not pick up replaced files.
Disabling. With your full Compose command, run stop lan-https and then rm -f lan-https;
only then move the private lan-https.yml out of the private directory. The wrapper stops
including the proxy once that file is gone. Keep the certificates for rollback.
Adding it to an existing installation. Do not rerun setup. Generate a certificate and key
with OpenSSL outside the checkout, set CHORUS_TLS_CERT_FILE, CHORUS_TLS_KEY_FILE, and
CHORUS_HTTPS_PORT in the private env, copy deploy/self-host/compose.lan-https.yml to
lan-https.yml in the private directory, and run the wrapper (which also redeploys the app).
Optional Cloudflare Named Tunnel
Section titled “Optional Cloudflare Named Tunnel”To publish the stack over HTTPS without opening host ports, use compose.tunnel.yml instead
of compose.local.yml. Never combine the two primary modes; the LAN HTTPS add-on can still be
layered separately. The tunnel publishes no host ports, and TLS terminates at Cloudflare.
You supply the Cloudflare account, the domain, and a remotely managed Named Tunnel; Chorus
does not provision them. In the Cloudflare dashboard, add a published application hostname and
set its origin service to http://app:8637, not localhost.
- Save the connector token to a private file (for example
tunnel-tokenin the private directory, mode0444inside the0700directory). Do not put it in shell arguments or history. - In the private env, set
CHORUS_PUBLIC_URLto the externalhttps://origin (no trailing slash),CLOUDFLARED_IMAGEto a pinnedcloudflare/cloudflaredrelease 2025.4.0 or newer, andCLOUDFLARED_TOKEN_FILEto the token file’s absolute path. - Keep the current complete
dcdefinition for rollback. Prepare the tunnel invocation, validate it and pull only the connector before stopping the local stack. Reuse the installed app and database images; this also works with an unpublished local build.
Preserve database-volume and authentication overrides. This example includes pg17.yml and
oidc-only.yml when present, then applies the tunnel policy last. Do not automatically carry
over https.yml or lan-https.yml. Inspect other custom overrides and retain required
non-exposure settings separately, without host ports or tunnel URL/cookie changes.
PRIVATE_DIR="$HOME/.config/chorus-self-host/$PROJECT"tunnel_dc() { local compose_files=(-f "$REPO/deploy/self-host/compose.yml") local override for override in pg17.yml oidc-only.yml; do if [ -f "$PRIVATE_DIR/$override" ]; then compose_files+=(-f "$PRIVATE_DIR/$override") fi done compose_files+=(-f "$REPO/deploy/self-host/compose.tunnel.yml") docker compose --project-name "$PROJECT" --env-file "$ENV_FILE" \ "${compose_files[@]}" "$@"}tunnel_dc config --quiet &&tunnel_dc pull cloudflared &&dc down &&tunnel_dc up -d --pull never --waitAfter a successful switch, use the tunnel invocation for all later dc commands:
dc() { tunnel_dc "$@"; }The connector runs tunnel --no-autoupdate run --token-file /run/secrets/tunnel_token, and the
override sets NEXTAUTH_URL to CHORUS_PUBLIC_URL and COOKIE_SECURE=true. The saved wrapper
always uses compose.local.yml, so manage a tunnel-mode stack with this explicit dc command.
To switch back, down with the tunnel function first, then up with the local one. Never use
down -v.
On the Cloudflare side:
- Bypass the cache for the whole Chorus hostname, or at least for authenticated pages, login
routes,
/api/*,/api/mcp,/api/events, and/api/events/notifications. - Check that Server-Sent Events arrive incrementally through the full path.
- Interactive Cloudflare Access challenges turn API, MCP, and daemon requests into HTML or redirects. Chorus API keys do not satisfy Access by themselves.
- Keep the tunnel token secret; rotate it in Cloudflare if it leaks.
Worked example: LAN HTTPS plus a separate Cloudflare connector
Section titled “Worked example: LAN HTTPS plus a separate Cloudflare connector”Advanced: keep LAN access while publishing through a separate connector
One verified single-host layout keeps local mode for the LAN and publishes the same app
through Cloudflare without switching to compose.tunnel.yml. It runs four containers:
| Container | Comes from | Role |
|---|---|---|
<project>-app-1 | compose.yml + compose.local.yml | Chorus on host port 18637 (container 8637) |
<project>-db-1 | compose.yml (+ private pg17.yml after a migration) | PostgreSQL 17, internal only |
<project>-lan-https-1 | private LAN HTTPS override | Nginx on host port 18638, forwarding to http://app:8637 |
chorus-cloudflare | a standalone docker run | Cloudflare connector, forwarding the public hostname to http://app:8637 |
The installer keeps env, lan-https.yml, and lan-tls/ in the private directory.
The saved default_deploy_local.sh stays in the checkout unless CHORUS_DEPLOY_CONFIG
selects another location. After a migration, a private pg17.yml can select the new database
volume. Use the same overrides in your manual dc function as in the wrapper.
To build it:
- Install local mode with LAN HTTPS as described above, and check both
http://<host>:18637/api/healthandhttps://<host>:18638/api/health. - In the Cloudflare dashboard, give the tunnel’s published hostname the origin service
http://app:8637. - Before publishing, set
NEXTAUTH_URLto the publichttps://origin andCOOKIE_SECURE: "true"underapp.environmentin the privatelan-https.yml, preserving its existing proxy service. Redeploy with the complete Compose invocation if you useoidc-only.ymlor other custom overrides; otherwise use the wrapper. Verify LAN HTTPS login first. - Save the connector token to a private file (as in the tunnel section), then attach the connector
to the stack’s default network,
<project>_default:
docker run -d --name chorus-cloudflare --restart unless-stopped \ --network "${PROJECT}_default" \ -v "$HOME/.config/chorus-self-host/$PROJECT/tunnel-token:/run/secrets/tunnel_token:ro" \ cloudflare/cloudflared:<pinned-version> \ tunnel --no-autoupdate run --token-file /run/secrets/tunnel_tokenKnow the trade-offs of this layout before you adopt it:
- The connector is outside Compose. The wrapper,
dc ps, anddc downdo not manage it. Beforedc down, rundocker stop chorus-cloudflareanddocker rm chorus-cloudflare.downremoves<project>_default, so re-run thedocker runabove afterdc up. Stopping the connector only takes the public hostname offline; LAN access keeps working. - Keep the token out of the command line. A
--token <value>argument is visible indocker inspectand process listings. Use--token-filewith cloudflared 2025.4.0 or newer, and rotate the token in Cloudflare if it was ever passed inline. - Host ports stay published. Unlike tunnel mode,
18637and18638remain open on0.0.0.0, so restrict them to trusted networks with Docker-aware firewall rules. - HTTPS-only login is required. The connector bypasses the LAN proxy, so the app must
set Secure cookies itself. The required override disables plain-HTTP login on
18637; use LAN HTTPS or the public HTTPS hostname. - The Cloudflare cache, Server-Sent Events, and Access guidance in the tunnel section applies unchanged.
PostgreSQL 17 and existing PostgreSQL 16 volumes
Section titled “PostgreSQL 17 and existing PostgreSQL 16 volumes”New installations use postgres:17-alpine, in deploy/self-host/ and in the repository’s
root docker-compose.yml.
The repository ships an explicit offline migration helper,
deploy/self-host/migrate-pg16-to-17.sh, documented in full in docs/POSTGRES_UPGRADE.md
(Chinese: docs/POSTGRES_UPGRADE.zh.md). In summary:
- Pre-pull
postgres:16-alpineandpostgres:17-alpine; the helper does not pull images. - Back up the deployment configuration and app signing material. Stop every writer (app,
CLI, dev server) and the source database. For an installer-managed instance, stop
appanddbwith its full Compose command; do not rerun the installer. - Find the source volume name with
docker inspecton the project’s database container, and write the database role password (not a Chorus login password) to a private0600file. - Run the helper with
--dry-run, then again without it. The backup directory must not exist yet, and the target names must be unused:
bash deploy/self-host/migrate-pg16-to-17.sh \ --source-volume YOUR_OLD_PG16_VOLUME --target-volume chorus-pg17-migrated \ --backup-dir /absolute/private/path/pg17-backup \ --password-file /absolute/private/path/db-password --writers-stopped --dry-runThe helper never mounts the original volume writable. It copies the stopped PG16 cluster to a
working volume, dumps it from an isolated PG16 container, restores into a new PG17 volume, and
compares public-table row counts and sequence states. It keeps all volumes and backups whether it
succeeds or fails, and it only supports a single default-layout database in the public schema.
- For an installer-managed instance, point the stack at the new volume with a private
pg17.ymlin the private directory and setPOSTGRES_IMAGE=postgres:17-alpinein the private env:
volumes: db-data: external: true name: chorus-pg17-migratedValidate the database with the same app image first. After that, the wrapper includes
pg17.yml on every run. Rolling back before new writes means selecting the original volume
and a PG16 image; once PG17 has accepted writes, switching back loses them.
Verify the deployment
Section titled “Verify the deployment”-
dc psshowsappanddb(andlan-https, if enabled) healthy. -
The health endpoint reports the database:
Use the local origin with your chosen port, or the public HTTPS origin in Tunnel mode (which publishes no host port). Set
BASE_URLaccordingly:Terminal window BASE_URL=http://localhost:18637curl --fail --silent --show-error "$BASE_URL/api/health"Expect
status: okanddatabase: connected. -
Sign in from a browser, create a disposable project and idea, reload, and confirm it persists. Over HTTPS, check that the session cookie is Secure and HttpOnly.
-
Sign in to
/adminwith the super admin account. -
Optionally, create a test agent API key and run
chorus mcp whoamifrom a client withCHORUS_URLset to this origin; see The chorus CLI. -
Run
dc up -d --force-recreate appand confirm you are still signed in. This proves the signing key inapp-datapersists.
Back up and restore
Section titled “Back up and restore”The database lives in db-data, and /app/data (including the generated signing key) lives in
app-data. dc down keeps both; dc down -v destroys them. In a maintenance window, stop
the app, take a logical dump, archive app-data, and copy the private env:
( set -euo pipefail umask 077 BACKUP_DIR="$HOME/chorus-backups/$PROJECT/$(date -u +%Y%m%dT%H%M%SZ)" mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" dc stop app dc exec -T db pg_dump -U chorus -d chorus -Fc > "$BACKUP_DIR/database.dump" dc run --rm --no-deps -T --entrypoint tar app \ -C /app/data -czf - . > "$BACKUP_DIR/app-data.tar.gz" cp "$ENV_FILE" "$BACKUP_DIR/env" dc start app)The subshell stops on any failure; a partial directory is not a valid backup. Investigate
and repeat the backup before upgrading. If the app remains stopped, deliberately run
dc start app after handling the error. Preserve your Compose overrides and image versions
with the backup so a restore retains the same authentication and storage configuration.
Keep the backup directory private (0700) and encrypt it before sending it off-host. Rehearse
restores into a new Compose project and port, never over the original. The upstream guide
walks through the restore rehearsal in detail.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Check |
|---|---|
| Setup says the private directory, script, or project resources already exist | This is an existing installation. Run its wrapper, or choose a new --project and CHORUS_DEPLOY_CONFIG for a separate instance. |
| Compose rejects missing variables | Use the private absolute --env-file, keep DEFAULT_USER and DEFAULT_PASSWORD non-empty, and select only one primary mode. |
| Port already in use | Choose another --port or --https-port. Do not delete existing volumes to resolve it. |
db fails right after switching to PostgreSQL 17 | The volume still holds PG16 data. Keep it and follow the migration above. |
| Database authentication fails after editing the env | An existing volume keeps the old role password; changing POSTGRES_PASSWORD does not change it. |
| HTTP login fails immediately | Local mode needs COOKIE_SECURE=false (from compose.local.yml); a private https.yml that sets it to true blocks LAN HTTP login. |
| Browser rejects the HTTPS endpoint after a month | The 30-day certificate expired; renew it and recreate lan-https. |
OIDC fails with redirect_uri_mismatch | Register the exact HTTPS callback, including the port, with the provider. |
| Users are signed out after recreate | The app-data volume or the explicit NEXTAUTH_SECRET changed. |
| Tunnel is healthy but the hostname errors | Check the published hostname, the http://app:8637 origin, app health, and token file readability. |
| CLI receives HTML from the tunnel hostname | An Access challenge, a cache or Worker rule, or a wrong /api/mcp URL. |
Never share env files, tokens, cookies, or unredacted logs while troubleshooting, and do not run host-wide Docker prune commands.