CodeHerderSearch⌘KRequest access →

Self-hosted deployment

Download, verify, run, and upgrade a self-hosted CodeHerder server on your own PostgreSQL database, and confirm which build is live, on the Enterprise plan.

The Enterprise plan lets you run CodeHerder on your own infrastructure instead of the hosted service. This page covers downloading a server release and starting it. For editions, see Plans and limits. To install your licence, see Install the licence. For licence expiry and renewal, see Self-hosted licence expiry and renewal.

Before go-live

Work down this list in order. Each item links its section or page.

  1. Check you have access to the self_host entitlement, then download a build. To run Core, build from source instead.
  2. Verify the signature and checksum of a download. Install the licence before first boot.
  3. Escrow the four keys before first boot.
  4. Point the server at your database and use sslmode=verify-full.
  5. Choose your AWS region.
  6. Set the public origins.
  7. Set up mail.
  8. Put a reverse proxy in front, terminate TLS and serve the web app and the preview origin. Keep the server unreachable from untrusted networks until step 10 creates the first account. See create the first account.
  9. Harden the host. Read what the server enforces and what you own.
  10. Connect your identity provider, close self-service signup and create the first account.
  11. Back up and escrow, and rehearse a restore.
  12. Connect your CLI and devices and pick an execution mode. See Executor isolation.
  13. Read where your data goes in Self-hosted data residency.
  14. Agree who owns credentials, devices and contacts, and sign the acceptance record in Self-hosted responsibilities and launch acceptance.
  15. Run the checks in Verify Cognito before go-live.
  16. Run the Self-hosted acceptance journey in each execution mode you use.
  17. Read Upgrade to a new release and Roll back a failed upgrade before you need them.

Check you have access

Self-hosted deployment is an Enterprise feature, gated by the self_host entitlement. Run ch workspace plan and look for self_host in the Entitlements: line:

$ ch workspace plan
...
Entitlements: branding, budgets_spend_caps, capability_routing, cost_dashboards, rest_api_cli, scim, self_host, sso_saml, webhooks, workflow_rigor

See Check your plan from the CLI for the rest of that command’s output. If self_host is missing, contact sales from the Plan page to upgrade.

Read the manifest

Query the manifest to find the current release and the build for your platform:

curl -H "Authorization: Bearer $CH_TOKEN" {{api_base}}/v1/self-host/manifest

Any signed-in member of an entitled workspace can call this with their own API key — you don’t need to request a separate download key. The response’s data object carries the release version and a builds array. Each build entry has os, arch, filename, size, and a sha256 checksum. Pick the entry that matches your machine, and read the filename and sha256 straight from that response. Don’t hard-code them — they change on every release.

The data object also carries a migrations object. It lists the schema migrations this release adds since the previous release, and says whether any may lock a table (mayLock) or run long (mayRunLong). It is null for a release published before the notice existed. Read it before you upgrade. See Upgrade the server and plan maintenance.

The manifest names the current release. You can also download any release published in the last 90 days. Put that release’s version in the download path. See Supported versions.

Download a build

Use the filename from the manifest in this request:

curl -L -H "Authorization: Bearer $CH_TOKEN" \
  {{api_base}}/v1/self-host/artifacts/<VERSION>/<FILENAME> -O

<VERSION> is the manifest’s version value — a leading v is optional, so either form works. -L is required: this route redirects to a temporary download link rather than streaming the file itself, and the link expires in a few minutes.

To fetch a release you pinned earlier, use its version and its own filenames. Every release lists its filenames in its signed manifest.json. Download that file first with the same request: put the pinned version in <VERSION> and manifest.json in <FILENAME>, for example {{api_base}}/v1/self-host/artifacts/10.44.0/manifest.json.

A release older than 90 days returns 404 not_found.

Verify the signature, then the checksum

Every release manifest carries a detached signature. Check it before you trust any checksum.

Download manifest.json and manifest.json.sig from the same artifacts route, using the same <VERSION>. Then run:

openssl dgst -sha256 -verify release-public.pem -signature manifest.json.sig manifest.json

openssl must print Verified OK. The public key is internal/releasesig/keys/ in the CodeHerder source. If it fails, stop and contact support.

If CodeHerder finds a compromised release, it will tell you the affected versions and the new signing key fingerprint. You must then verify the manifest signature again and reinstall any host or device that ran an affected version.

Then confirm the archive matches the verified manifest’s sha256 for that build:

sha256sum <FILENAME>

If the hashes don’t match, don’t extract or run the archive — download it again. The install-codeherder-server.sh script runs both checks for you.

Fetch the SBOM

Every release has a software bill of materials (SBOM) for each artifact. The format is CycloneDX JSON. The SBOM of a file is the file name plus .cdx.json. Fetch it from the same artifacts route, with the same download key:

curl -L -H "Authorization: Bearer $CH_TOKEN" -O \
  {{api_base}}/v1/self-host/artifacts/<VERSION>/<FILENAME>.cdx.json

Use the file names from the verified manifest.json. Each release has an SBOM for every server archive, for the webapp archive, and for the sandbox image (codeherder-sandbox-<VERSION>-linux-arm64.cdx.json).

Check the signature first. Fetch <FILENAME>.cdx.json.sig the same way. Verify it with a release public key (the same key that verifies manifest.json.sig):

openssl dgst -sha256 -verify release-public.pem \
  -signature <FILENAME>.cdx.json.sig <FILENAME>.cdx.json

Use the SBOM only if this prints Verified OK. Then check the binding. The SBOM field metadata.component.version must equal sha256: plus that file’s sha256 in the verified manifest. If it differs, do not trust the SBOM.

Each artifact also has a build provenance statement, <FILENAME>.intoto.json, with its signature <FILENAME>.intoto.json.sig. Fetch both from the same route and verify them with the same openssl command. The statement names the artifact sha256, the source tag and commit, and the CI job that built it.

The ch binaries have the same four files beside them on the CLI download host, at /cli/v<VERSION>/<binary>.cdx.json, .cdx.json.sig, .intoto.json and .intoto.json.sig. An example binary is ch-linux-amd64.

What’s in the bundle

Each platform’s archive holds the codeherder server binary, plus two operator tools for occasional use: one that loads an existing SQLite-backed deployment into PostgreSQL, and one that prints a read-only summary of a database’s schema. A fresh install needs neither — you only reach for them if you’re migrating an older deployment or inspecting a running one.

Build from source

Core is open source. You can build it without the Enterprise download. Run these steps from a checkout of the release tag you want:

make build        # the server binary, ./codeherder
make install-ch   # the ch CLI, in ~/.local/bin/ch
cd app && npm ci && npm run build
VITE_SPA_ORIGIN=https://app.example.com npm run build:preview

The web app build is in app/dist. The preview build is in app/preview-dist. Set VITE_SPA_ORIGIN to your web app origin. The viewer takes it at build time. Without it, the viewer stays inert and no prototype renders. You serve both as static files. See Serve the web app and the preview origin. A make build binary carries no release signature and no built-in server address. Check out a v* tag so the build takes that tag as its version. See docs/versioning.md. To sign and publish your own build, see docs/release-signing.md.

A source build is the same server. The production checks below apply to it in the same way.

Install the licence

Get the licence file from your support contact. It is one signed token. Install it before first boot.

sudo install -o root -g codeherder -m 0640 license.token /etc/codeherder/license.token

Then set CH_LICENSE_FILE=/etc/codeherder/license.token in the unit. The reference drop-in 20-secrets.conf does this. See Hardening a self-hosted server. Set one of CH_LICENSE or CH_LICENSE_FILE. If you set both with different values, the server exits at boot.

At boot the server logs license: resolved with edition=enterprise and state=active. Then run ch workspace plan. The output must show Edition: enterprise.

With no licence the server runs the community edition. Self-service signup is open by default. SSO, SCIM and the other enterprise features are off. Plan limits are low. A licence that has lapsed past its grace window also drops to community. For renewal, see Self-hosted licence expiry and renewal.

Start the server

For firewall and IAM setup, see Self-hosted infrastructure.

CodeHerder encrypts secrets at rest by default, so it needs four keys before it will start. Save them in escrow before the first boot. A key you lose cannot be rebuilt, and every restart needs the same four values. Follow Escrow the four keys before first boot. For production, also set CH_ENV=production and CH_REQUIRE_STRONG_AT_REST_KEY=1. See Self-hosted key custody.

Generate each key with openssl rand -hex 32, store it as that page describes, and then set them as environment variables:

export CH_WEBHOOK_SECRET_KEY=$(openssl rand -hex 32)
export CH_INTEGRATIONS_SECRET_KEY=$(openssl rand -hex 32)
export CH_OTP_HMAC_KEY=$(openssl rand -hex 32)
export CH_INTEGRATIONS_OAUTH_STATE_KEY=$(openssl rand -hex 32)

Run these lines once, on a new database only. Never generate a new key for a database that already holds data. See Never mint a new key on an existing database. To replace a key on purpose, follow Rotating self-hosted at-rest keys.

Then point --db at an empty PostgreSQL database and start the binary:

./codeherder --db "postgres://user:pass@host:5432/codeherder"

For a production database, use the DSN form and the key settings in Self-hosted database encryption and TLS. Use sslmode=verify-full. See Require verified TLS on every connection.

CodeHerder applies every pending schema migration to that database before it starts serving requests. PostgreSQL is the only backend a self-hosted server supports. On a fresh database this takes a few seconds. Expect a longer pause the first time you upgrade an existing, heavily used database across several migrations at once.

Choose your AWS region

Set CH_AWS_REGION once. Set a surface knob only when that resource is in another region. A surface knob wins over CH_AWS_REGION. If both are unset, the server uses us-west-2.

Knob What it sets If unset
CH_AWS_REGION Every server AWS client us-west-2
CH_S3_REGION The attachments bucket and the revocation journal CH_AWS_REGION
CH_KMS_REGION The secret-store KMS key CH_AWS_REGION
CH_SES_REGION The SES mailer CH_AWS_REGION
CH_COGNITO_REGION The Cognito user pool CH_AWS_REGION
CH_SANDBOX_ECR_REGION The sandbox image registry (ECR) CH_AWS_REGION
CH_SERVER_RELEASES_REGION The vendor release bucket. A self-host leaves it unset. CH_AWS_REGION

Two regions are not server knobs:

  • CH_CLAUDE_AWS_REGION is a device knob for the Bedrock route. It does not inherit CH_AWS_REGION. Set it in the device environment.
  • The microVM runner region is a workspace setting. It is not an environment variable.

Upgrade note: before this release, KMS, ECR and releases used CH_S3_REGION, and SES used CH_COGNITO_REGION. If you set only those, set CH_AWS_REGION too. The attachments default also moved from us-east-1 to us-west-2.

Set the public origins

A production deployment uses three public hosts. Each host is its own origin:

Host Serves Example
API The server. You point ch and the web app at it. https://codeherder.example.com
Web app The static web app build. https://app.example.com
Preview The static prototype viewer. https://preview.example.net

Set the first two in the server environment:

CH_BASE_URL=https://codeherder.example.com
CH_APP_BASE_URL=https://app.example.com

The server builds mailed links from these values. It also admits the web app origin for browser requests. With CH_ENV=production, the server refuses to start unless both are https:// URLs. Outside production, a loopback listener fills an empty value with http://127.0.0.1:<port>. A production server never does that.

Set CH_ALLOWED_ORIGINS only for extra browser origins. Never set CH_DEV_ORIGINS in production. The server refuses it.

The preview host is your job. The server does not check it. Set CH_PREVIEW_ORIGIN to the preview origin so the docs the server renders name it. Then tell the web app where the API and the preview are. Write config.js next to the web app build:

window.__CH_CONFIG__ = {
  apiBase: "https://codeherder.example.com",
  previewOrigin: "https://preview.example.net",
};

If you omit previewOrigin, the web app falls back to its own origin. That fallback is not a security boundary. Always set a separate preview origin.

Pin the AWS device template

The Launch on AWS button reads two more config.js keys:

  • awsDeviceTemplateUrl: the S3 URL of the CloudFormation template. The default is CodeHerder’s public template, https://codeherder-cloudformation.s3.us-west-2.amazonaws.com/device.yaml.
  • awsDeviceRegion: the region the console opens in. The default is us-west-2.

The default object is mutable. CodeHerder can change it at any time. To fix the template, copy it to your own S3 bucket under a versioned key, for example device-1.2.3.yaml. Never overwrite that key. Then set both keys:

window.__CH_CONFIG__ = {
  awsDeviceTemplateUrl: "https://my-bucket.s3.eu-west-1.amazonaws.com/device-1.2.3.yaml",
  awsDeviceRegion: "eu-west-1",
};

CloudFormation loads a template only from S3. CH_AWS_REGION does not set this region. It is a server setting.

Set up mail

CH_ENV=production or an enterprise licence makes mail required. CodeHerder sends invitation notices, email-change codes and signup codes. Set these variables: CH_MAILER=ses, CH_MAILER_FROM (for example CodeHerder <no-reply@example.com>) and CH_AWS_REGION, or CH_SES_REGION when SES is in another region (for example us-west-2).

The server refuses to start with the fake mailer or a failed SES setup. This rule also applies when CH_MAILER is unset. Set CH_ENV=production as well, to switch on the other production security checks.

The server only calls SES. You own the SES setup: a verified sender identity for the CH_MAILER_FROM domain, SPF and DKIM records, and credentials from an instance role or a secrets file. The fake mailer never sends mail. It hides one-time codes unless CH_MAILER_LOG_CODES=1 is set. Never set that in production. See Self-hosted database encryption and TLS.

GET /v1/health returns these fields:

  • status: ok, or the reason for a 503: db_unavailable, identity_unavailable or mail_unavailable.
  • db: ok, writer_saturated or unavailable.
  • identity: off, ok, no_keys or init_failed. See Connect your identity provider.
  • mail: ses, fake or init_failed. init_failed means SES setup failed and the server fell back to the fake mailer. A server that requires mail never reports it, because it refuses to start.
  • atRestKeys: ok, mismatch, unreadable_rows or not_checked. The status stays 200 when the value is not ok. See Alert on an at-rest key mismatch.

Point your monitoring at this endpoint.

Supported versions

A release is supported for 90 days after it is published. The latest release is always supported.

  • Security fixes ship forward only. A fix ships in a new release. There are no backports to an old release. You get a fix when you upgrade to a release that contains it.
  • Notice: your support agreement says how you hear about a release that contains a security fix. See the notice rule below.
  • Supported means: you can download the release, and you can upgrade from it to the latest release. Your support agreement says what support you get for it.
  • Unsupported means: the release is older than 90 days. It gets no fixes. The download API returns 404 not_found. The files are deleted after 120 days. Upgrade to a current release.

Supported combinations

Take the server version as N. Compare each other part to the server’s MAJOR.MINOR line.

Part Supported
Server The latest release, or any release under 90 days old.
Device (ch device-server) Same MAJOR. Up to 2 minor releases behind the server. Any minor ahead.
CLI (ch) Same MAJOR. The same MINOR as the server, or newer. ch start check flags a CLI that is behind.

Upgrade order

  1. Back up the database. See Upgrade to a new release.
  2. Upgrade the server.
  3. Wait for a healthy /v1/health answer.
  4. Let the devices update. They update themselves.
  5. Run ch self-update on each CLI.

Never upgrade a device before the server. A MAJOR upgrade moves the server and all devices together.

Device patch deadline

A device is behind when it is 3 or more minor releases behind the server, or on an older MAJOR. It is also behind when it runs a version below CH_DEVICE_PATCH_MIN_VERSION. A behind device fails the version_current check. After CH_DEVICE_PATCH_GRACE (7 days), it gets no new sessions. To update a stuck device, see Updating the CLI.

When a security fix ships

The fix is in a new release and in every later release. That release stays downloadable for 90 days from its publication. After a security release, upgrade the server. Then set CH_DEVICE_PATCH_MIN_VERSION to the fixed version. For how you hear about a fix, see the notice rule below. The clock for a vulnerability is in Vendor notification clock.

Rule text for approval

This text is a template. Your support agreement sets the values. Fill in each blank.

Security release notice rule.

  1. The support contact tells the customer communication owner, through ______, when a release contains a security fix. The notice goes out within ______ of the release, and within ______ of confirming the vulnerability at the latest.
  2. A vulnerability with signs of use is a security incident. The vendor notification clock in “Self-hosted support and incident response” applies instead.
  3. The notice names the fixed version, the affected versions, the severity, what to do now, and the CH_DEVICE_PATCH_MIN_VERSION value to set.
  4. The notice names an upgrade-by date. For a high or critical fix it is at most ______ after the notice. A release that is not upgraded by then is outside support for that issue.
  5. A fix ships forward only. There are no backports. A fixed release stays downloadable for 90 days.

The full policy is in the CodeHerder SECURITY.md file.

Upgrade to a new release

Upgrading follows the same steps as your first install: read the manifest, download the new build, verify it, then restart on it.

  1. Take a database backup first. See Back up the database. Record the version you run now with curl https://<your-server>/v1/version. A dump works: pg_dump -Fc -f pre-upgrade.dump "$DSN". Keep the dump and the current archive until the new release is healthy. You need both to roll back.
  2. Query the manifest again to confirm the current release. See Read the manifest above. Read its migrations object.
  3. Download and verify the new build. See Download a build and Verify the signature, then the checksum above.
  4. Run codeherder migration-notice -db "$DSN" with the new binary. It lists the migrations your database has not applied and flags any that may lock a table or run long. It writes nothing. The manifest covers one release. When you skip releases, this preflight is the full answer. If anything is flagged, schedule a maintenance window. See Upgrade the server and plan maintenance.
  5. If the release notes say it drops aesgcm-v1, reseal first. See Before you upgrade past the release that drops aesgcm-v1. Then stop the running server, then start the new binary in its place with the same four keys and the same --db value. See Start the server above. CodeHerder applies any pending schema migrations before it starts serving requests, so there’s nothing extra to run yourself. While /v1/health reports migrating, wait. Do not stop the server with SIGTERM.
  6. Confirm the new build is live. See Confirm which build is live below.

The migrations run before the server listens. The server is down from your stop to its first healthy response. A rehearsal upgrade across 32 migrations on a near-empty database took under one second. Expect much longer on a large database.

Roll back a failed upgrade

Schema migrations only go forward. The upgrade changes your database in a way the older binary does not undo. Never start the older binary on an upgraded database. The older binary does not refuse to start there, and how it behaves on the newer schema is undefined. Restore the database backup instead. The full restore order, with the keys and the attachments bucket, is in Recover.

  1. Stop the new server.

  2. Restore the pre-upgrade backup from Back up the database into an empty database. For a snapshot, restore it as your provider documents. For a dump, drop and recreate the database, then restore:

    pg_restore --no-owner -d "$DSN" pre-upgrade.dump
  3. Get the prior bundle. Use the archive you kept. Or download it by its version, if it was published in the last 90 days. Verify it as in Verify the signature, then the checksum.

  4. Install the prior webapp tarball from the same release, if you serve the dashboard yourself.

  5. Start the prior binary with the same four keys and the restored --db value.

  6. Confirm the prior version answers. Run curl https://<your-server>/v1/version, or ch whoami.

Data loss: everything written after the backup is lost. That includes work done while the new release ran. Downtime: from step 1 until the first healthy response in step 6, plus the restore time. In a rehearsal on a near-empty database, the restore took under two seconds and the prior binary started in under one second. Restore time grows with database size.

Use the same four keys you used before the upgrade. A different key makes the server refuse to start.

Confirm which build is live

A version number alone doesn’t prove your restart came up on the new binary. The build reference is what actually confirms it.

Run ch whoami and check the server row:

$ ch whoami
...
server: 2.4.0 (a1b2c3d)
...

When the server binary is stamped with a build reference, server shows it in parentheses next to the version. Compare that reference before and after a restart: if it changed, the new binary is answering requests; if it didn’t, something is still serving the old one.

Prefer not to sign in? The same information is available with a plain request:

curl https://<your-server>/v1/version

This returns the version and, when the build is stamped, the same build reference. No token needed.

If your CLI warns you that the server is behind or ahead of it, see Updating the CLI for what each direction means.

Put a reverse proxy in front

Warning: A fresh install admits the first verified caller as its first account. Do not expose the proxy to untrusted networks before you create the first account. Until then, allow only your own address at the edge.

Run a reverse proxy in front of the server and redact credentials from its access log before they reach disk. A raw access log otherwise carries the Authorization header, session cookies, the WebSocket handshake header, and secret query parameters from an OAuth redirect — all in plain text.

The browser signs in through three routes under /v1/auth/browser-session/ that set and read an HttpOnly refresh-token cookie. The proxy must serve the site over HTTPS and forward Cookie and Set-Cookie on those routes unchanged. If the proxy adds a path prefix before the API (for example /api), set CH_AUTH_COOKIE_PATH to the path the browser sees, such as /api/v1/auth/browser-session. Otherwise the browser never sends the cookie and every reload signs the user out.

CodeHerder’s own hosted deployment uses this Caddy snippet, tested in CI against a pinned Caddy release:

(ch_access_log) {
	output stdout
	format filter {
		wrap {args[0]}
		fields {
			request>headers>Authorization delete
			request>headers>Proxy-Authorization delete
			request>headers>Cookie delete
			request>headers>Sec-Websocket-Protocol delete
			request>headers>X-Ch-Edge-Secret delete
			request>headers>X-Api-Key delete
			request>headers>X-Gitlab-Token delete
			request>headers>Referer regexp "\?.*" "?REDACTED"
			request>uri regexp "\?.*" "?REDACTED"
			resp_headers>Set-Cookie delete
			resp_headers>Sec-Websocket-Protocol delete
			resp_headers>Location regexp "\?.*" "?REDACTED"
		}
	}
}

Import it from each site block’s own log directive, picking the wrapped format you want (console or json):

example.com {
	reverse_proxy 127.0.0.1:7878
	log {
		import ch_access_log console
	}
}

This redacts:

  • Request headers: Authorization, Proxy-Authorization, Cookie, Sec-WebSocket-Protocol, X-CH-Edge-Secret, X-Api-Key, X-Gitlab-Token.
  • The Referer request header and the request URI: the query string is stripped from both, not just a named list of parameters — this covers an OAuth code/state pair and any other secret query value.
  • Response headers: Set-Cookie, Sec-WebSocket-Protocol.
  • The Location response header: the query string is stripped, covering an OAuth redirect’s token and a presigned download URL’s signature.

Running a different reverse proxy? Apply the same redaction list: the same request/response headers, plus stripping the query string from any logged URI, Referer, or Location value. To ship these logs off the host, see Self-hosted logs. To set log retention and readers, see Self-hosted log retention and access. To alarm on mail, spend and security events, see Mail, spend and security detections. To ask for help, run ch instance support-bundle and send the file. For a device that keeps dropping, or a task stuck behind a provisioning breaker, follow Diagnose device tunnel failures or Release a provisioning breaker first. The support bundle page lists every field. If you suspect the host is compromised, see Replacing a compromised self-hosted server. For who to call and how fast, see Self-hosted support and incident response. To turn these logs into service objectives, see Self-hosted service objectives.

Rate limits

The server limits each signed-in caller. It also limits each IP address that sends bad credentials. A limited request gets status 429 with a Retry-After header.

Your proxy must replace the X-Forwarded-For header with the real client address. Do not append to it. If the proxy appends, a client can choose its own limit bucket. If the proxy sends no address, all clients share one bucket.

You do not need a proxy-side rate limit. The rate_limit block in the sample Caddyfile can stay commented out.

The defaults are generous. Change them in the server environment:

  • CH_CALLER_RATE_BURST (default 2000) and CH_CALLER_RATE_PER_MIN (default 1200) set the limit for each signed-in caller.
  • CH_WORKSPACE_INFLIGHT_CAP sets how many requests one workspace may have in flight at once. The default is twice CH_READ_POOL, so it grows when you raise the reader pool. 0 turns it off.
  • CH_AUTH_FAILURE_BURST (default 100) and CH_AUTH_FAILURE_RATE_PER_MIN (default 60) set the limit for each IP that sends bad credentials.

Some actions have their own caps on top of the per-caller limit. Each cap is per member. A member over a rate cap gets status 429 with a Retry-After header:

  • Task creation. CH_TASK_CREATE_BURST (default 120) and CH_TASK_CREATE_PER_MIN (default 60). Error code: task_create_rate_limited. REST and MCP share this cap.

  • Tasks per agent session. A fixed limit of 100. Error code: session_task_cap_reached (status 409).

  • Task chain depth. A fixed limit of 10 levels. Error code: task_depth_cap_reached (status 409).

  • Team and workspace messages. CH_BROADCAST_SEND_BURST (default 30) and CH_BROADCAST_SEND_PER_MIN (default 10). Error code: broadcast_rate_limited.

  • Session wakes from those messages. CH_WAKE_FANOUT_BURST (default 500) and CH_WAKE_FANOUT_PER_MIN (default 200). Over the cap, the server keeps the message and wakes no session. The send still succeeds.

  • Attachment downloads. CH_DOWNLOAD_BURST (default 600) and CH_DOWNLOAD_PER_MIN (default 300) count requests. Error code: attachment_download_rate_limited. CH_DOWNLOAD_MIB_BURST (default 4096) and CH_DOWNLOAD_MIB_PER_MIN (default 1024) count megabytes. Error code: attachment_download_bytes_limited.

  • Publication imports. CH_IMPORT_BURST (default 20) and CH_IMPORT_PER_MIN (default 10) count import requests. Error code: import_rate_limited. CH_IMPORT_RECORD_BURST (default 20000) and CH_IMPORT_RECORDS_PER_MIN (default 5000) count the records in each import (tasks, comments, fields and the rest). Error code: import_records_limited. One import request may not exceed 1 MiB.

  • Session attach sockets. CH_ATTACH_MEMBER_SOCKET_CAP (default 8) caps the live attach sockets one member holds. Over it, the server answers 429 with error code too_many_attach_sockets. CH_ATTACH_SOCKET_CAP (default 256) caps all live attach sockets together. Over it, the server answers 503 with error code attach_capacity_reached. Both caps are per API process. With two replicas the real limit is twice the value. A closed socket frees its slot at once.

When a cap trips, the server records a member.cap_tripped event. The event names the cap and its limit. The total attach cap writes no event, because it is a server limit and not one member’s fault. The server writes at most one per member and cap every 10 minutes.

Raise the failure limit if many users share one address and you see auth_failure_rate_limited errors. For the full list of settings, see the configuration reference.

Put a WAF, VPN or allowlist in front

Place the edge control in front of the TLS proxy. The order is: client, then WAF, VPN or allowlist, then the TLS reverse proxy, then the server on 127.0.0.1:7878. Nothing else may reach the server.

You can close the web app and the bearer API to staff networks. Keep these paths reachable by their callers:

  • Devices and the CLI: GET /v1/device-tunnel is a WebSocket. Allow Upgrade and long-lived connections. Devices outside the VPN, such as laptops and EC2 hosts, need a route to it and to the bearer API. The CLI also reads /v1/version for build information.
  • Inbound webhooks: POST /v1/inbound/{id} from the GitHub or GitLab sender address ranges. Pass the body and the signature headers unchanged. The signature covers the raw body.
  • Sign-in: /v1/humans/bootstrap, /v1/humans/verify, /v1/auth/browser-session/*, /v1/oauth-callback, /v1/auth-config, and /v1/branding. Keep Cookie, Set-Cookie, Origin and the X-CH-CSRF header. If MCP is on, also allow /v1/mcp/oauth/* and /.well-known/*. The Cognito hosted sign-in page is served by the identity provider, not by your edge.
  • SCIM provisioning: /scim/v2/* from your identity provider’s address ranges, if you use SCIM. Okta and Entra call it from their own clouds.
  • Health checks: /health and /v1/health for your load balancer.
  • Live connections: /v1/ws and /v1/sessions/{id}/attach. The bearer token travels in Sec-WebSocket-Protocol. Do not strip it.

These rules break things:

  • A managed rule that inspects or rewrites request bodies can break webhook signatures.
  • A body-size rule must allow at least 1 MiB. The server caps request bodies at 1 MiB.

Keep these headers intact:

  • Pass 429 responses and their Retry-After header through unchanged.
  • Make the last proxy replace X-Forwarded-For with the real client address. An AWS load balancer or WAF appends to it, so overwrite it.
  • Do not rely on X-Real-IP or True-Client-IP. The server strips both.

A WAF rate rule is optional. Keep it looser than the server limits, so the server’s 429 and Retry-After reach clients.

Terminate TLS

Terminate TLS at the proxy on port 443. Bind the server to 127.0.0.1:7878 only, so nothing reaches it except through the proxy. Set CH_BASE_URL and CH_APP_BASE_URL to the https:// host that users type. Every link in mail uses them. The reverse proxy section of the hardening guide gives a reference Caddyfile and the X-Forwarded-For rule.

Serve the web app and the preview origin

The server serves the API only. It does not serve the web app or the preview. You serve both as static files from your proxy. You also install their security headers. The server sets none of them.

The hardening guide gives a Caddyfile with three host blocks:

  • The web app block sets a Content-Security-Policy with script-src 'self', and HSTS, nosniff and Referrer-Policy.
  • The preview block sets a stricter Content-Security-Policy, Cross-Origin-Resource-Policy: cross-origin, Access-Control-Allow-Origin: * (the sandboxed frame loads kit fonts in CORS mode), X-Robots-Tag, HSTS and nosniff.

The CSP values come from app/src/config/csp.ts and app/src/config/previewCsp.ts. A test holds the Caddyfile equal to them for the example hosts. If you change a host name, render the values again for your hosts. A configured origin does not prove that the headers are installed. Check them on every host with curl -sI. The hardening guide shows how.

Harden the host

Before real traffic, apply the production settings and the sandboxed systemd unit. Hardening a self-hosted server lists every security setting with its required production value. It also gives a reference unit, drop-ins and a Caddyfile. The hardening page shows each reference file in full: one systemd unit, four drop-ins and one Caddyfile. To check a running host against them, use Verify a self-hosted host.

Connect your identity provider

Pick one mode. Cognito mode reads the access token. A stock Cognito access token has no email, email_verified or identity_provider claim, so you must install the pre-token Lambda on your pool first. A Lambda that adds email but not identity_provider makes a federated user look native. That user then skips the federated signup gate. OIDC mode reads the ID token and needs no Lambda. See OIDC mode. Never run both modes at once.

GET /v1/health includes an identity field: off, ok, no_keys or init_failed. It returns 503 with status: identity_unavailable while your identity provider’s signing keys are not loaded. Point your monitoring at /v1/health. API keys keep working during this state. The server retries the key fetch every hour, and on the next sign-in attempt. Fix the cause and restart to recover at once.

The key URL must use HTTPS. Its host must match the issuer host. If your provider serves keys from another host, set CH_OIDC_JWKS_TRUSTED_HOSTS to that host after you review it. Cognito needs no setting.

Add each identity provider through a CodeHerder SSO connection and verify its domain. See SSO. Before go-live, run the checks in Verify Cognito before go-live. CodeHerder emails the workspace admins 30 days before a SAML signing certificate or SCIM token expires, and again when it lapses (CH_SSO_CREDENTIAL_EXPIRY_LEAD sets the 30 days). With no mail transport configured, only the sso.credential_expiring and sso.credential_expired events fire, so subscribe a webhook to them. CodeHerder does not trust an identity provider that you add straight to the pool. Its users cannot link to an existing member or create an account.

CodeHerder trusts these static names outright: Google, Facebook, LoginWithAmazon, SignInWithApple, Microsoft and GitHub. Use one of them only for the provider it names.

Federated signup is closed by default. To open it, set CH_SSO_FEDERATED_SIGNUP=true and list your domains in CH_SSO_ALLOWED_EMAIL_DOMAINS. Only a verified email can create an account. The server refuses to start when the list holds *. To allow *, also set CH_SSO_ALLOW_ANY_EMAIL_DOMAIN=true. The server then logs a WARN at every start.

Cognito mode

Set CH_COGNITO_USER_POOL_ID to turn Cognito mode on. Its presence is the switch. Then set the pool settings:

Knob What it sets
CH_COGNITO_USER_POOL_ID The user pool id. Setting it enables Cognito mode.
CH_COGNITO_REGION The pool’s region. Falls back to CH_AWS_REGION.
CH_COGNITO_DOMAIN The hosted-UI domain.
CH_COGNITO_SPA_CLIENT_ID The app client id for the browser app.
CH_COGNITO_CLI_CLIENT_ID The app client id for the ch CLI device-code flow.
CH_COGNITO_MCP_CLIENT_ID The app client id for the MCP connector.
CH_COGNITO_PROVIDERS The federated provider names on the pool, comma-separated.

Install the pre-token Lambda, grant the lifecycle permissions and turn on threat protection. See Self-hosted Cognito sign-in.

OIDC mode

Use OIDC mode when you bring your own OpenID Connect provider, or a Cognito pool that you do not want to customise. Set CH_OIDC_ISSUER to turn it on. Its presence is the switch. The server refuses to start when CH_COGNITO_USER_POOL_ID is also set.

Knob What it sets
CH_OIDC_ISSUER The issuer URL. Setting it enables OIDC mode.
CH_OIDC_SPA_CLIENT_ID The client id for the browser app.
CH_OIDC_CLI_CLIENT_ID The client id for the ch CLI device-code flow.
CH_OIDC_CLIENT_IDS Extra client ids to admit, comma-separated.
CH_APP_BASE_URL The web app origin. The redirect URI is <CH_APP_BASE_URL>/auth/callback.

The server uses the ID token as the bearer. A stock Cognito pool already puts email, email_verified and identities in the ID token. No Lambda is needed. In OIDC mode there is no pool, so deprovision revokes the CodeHerder keys and does not call the identity provider. Disable the person in your provider as well. For every knob and a worked example, see docs/oidc.md in the Core source.

Close self-service signup

CH_SIGNUP_DISABLED closes self-service signup. It defaults to closed only with an enterprise licence. An unlicensed install runs as Community and is open to every caller. Set CH_SIGNUP_DISABLED=true before you first expose the server, so the unit file states the choice. Everyone except the first account then joins by invitation.

Create the first account

With signup closed, the server admits exactly one first account while no account root exists. Create it with one native pool sign-in, or with one ch human bootstrap. A federated identity can never create the first account. See Bootstrapping the first account (self-hosted) and Self-hosted Cognito sign-in. Invite everyone else.

Warning: The first verified caller who reaches bootstrap or a native pool sign-in creates the first account. That caller owns its root workspace. Create the account before you expose the server. Until then, allow only your own address at the edge. Check GET /v1/health: it must show signupEnabled: false. That caller is not an instance operator. Operator rights come only from CH_INSTANCE_OPERATORS. If someone else claimed the account, stop. Drop the database and start again. A fresh install holds no data yet.

Then add that person’s verified email address to CH_INSTANCE_OPERATORS and restart the server. An instance operator runs the instance checks, the support bundle and the ch instance restore-fence commands. Nobody else can.

Back up and escrow

Back up the database, the attachments bucket and the revocation journal. Escrow the four keys outside the host. Restore into a scratch database before go-live. Follow Self-hosted backup and recovery, starting with what to back up and Rehearse a full restore. For key custody, see Self-hosted key custody.

Who can see what

Every member of a workspace reads all of its content. That covers tasks, messages, events, repositories, wiki pages and agent output. A member’s role changes what they can change, not what they can read.

A team is not an access boundary. Teams group people for channels and messages only. To restrict a project, put it in its own workspace.

A role on a group reaches every workspace beneath it. Group resources inherit to each child workspace: agents, repositories, devices, wiki pages, workflows, integrations and variables. See Workspaces and Members and teams.

A device linked to several workspaces also shares some components. See What a shared device shares.

Your administrator reads and signs the Administrator briefing.

Connect your CLI and devices

Once the server is running, point your CLI and device servers at it with CH_ADDR. See Credentials and profiles for setting that up (long-lived credentials never expire: see When credentials expire), and How do I add a device? for registering a device against a self-hosted server.

Before you share a device between workspaces, read What a shared device shares. To see what an agent can reach on a device, read Isolating agent runs on a device.

If your server doesn’t offer browser sign-in, create the first account with ch human bootstrap — see Bootstrapping the first account (self-hosted).

Pick the execution mode for each device before it takes work: process, --docker or --docker-executor. See Pick an execution mode.

What the server enforces and what you own

With CH_ENV=production, the server checks some settings at boot and refuses to start when one fails. Everything else is your job. Do not assume the server checks it.

The server refuses to start when You own
An at-rest key is missing or weaker than 256 bits Key escrow, rotation and recovery drills
An at-rest key does not match the sealed data in the database (a wrong or lost key) Keeping the escrowed keys with the backup they belong to
CH_DB names a non-local host without TLS, or has the compose default password. A loopback host and a Unix socket are exempt. The database, its backups and a restore drill
The mailer is fake, or SES fails to start SES identity, SPF, DKIM and credentials
CH_BASE_URL or CH_APP_BASE_URL is empty or not https:// The DNS records and the TLS certificates
CH_ALLOW_PLAINTEXT_SECRETS or CH_DEV_ORIGINS is on The reverse proxy, and its X-Forwarded-For rule
Another hardened knob in the hardening profile is weak The web app and preview headers, and a separate preview origin
The firewall and network boundary
The executor mode of each device

A device server runs the same kind of check for the device knobs. Run it with CH_ENV=production on each device.

Executor isolation

The server never runs agent code. A device runs it. The device mode sets the boundary between an agent and the host. Read Pick an execution mode before a device takes work. That page lists what each mode isolates and the risk that remains.

A mode with a hostile-agent boundary is --docker-executor, or a microVM runner. Process mode and the --docker launcher run in trusted mode. They give no such boundary. To refuse task stages on a device without one, set CH_REQUIRE_ISOLATED_EXECUTION=1 on that device. A device then runs bypassed-approval work only with a boundary, or with CH_TRUSTED_EXECUTION=1 as your written consent. This guide does not remove the residual risks on that page. Your organization must accept them before go-live.

Where your data goes

The server, the database, the attachments bucket, the KMS key and the Cognito pool stay in your AWS account. Self-hosted data residency lists every flow that leaves it: devices, the AI routes, GitHub and GitLab cloud, mail, and CodeHerder’s update and pricing endpoints.

Offboard a person

Use deprovision to remove a person’s access. Use the SSO connection’s SCIM, or run ch member deprovision <member>.

Deprovision does these things in one step:

  • It revokes every workspace role the person holds in the account, at every level.
  • It revokes their API keys, MCP grants and CLI installations in the account.
  • It revokes the agent keys the person minted. Session-bound keys and keys another admin minted stay live.
  • It stops the sessions that ran on the revoked keys.
  • It unlinks their devices from the account, revokes those devices’ tokens and closes their tunnels.
  • It disables their login when the account is their home account.

Also disable the user in your identity provider. Deprovision does not remove them there. To erase a person’s data, follow Self-hosted retention and data subject requests. To retire a whole install, follow the departure checklist.

Reactivation gives back the member role only. An admin grants any higher role by hand. Run ch member reactivate <member> at the person’s home workspace. It needs a free seat and restores no revoked key.

Protect the Docker socket proxy

If your devices run stages in Docker containers, they reach Docker through a socket proxy. The proxy refuses any caller that lacks a per-launch token. It also acts only on containers and networks it created itself.

  • sandbox/run-fleet.sh makes a new token at each launch. It stores the token in ~/.codeherder-fleet/socket-proxy.token. It gives the file to both containers. You do nothing more.
  • For a manual setup, put one secret in a file. Set SOCKET_PROXY_TOKEN_FILE on the proxy. Set CH_STAGE_DOCKER_TOKEN_FILE on the device server. Both point at that file.
  • The proxy will not start without a token file.
  • Run only stage containers through the proxy. It cannot see containers or networks that it did not create.
  • The proxy does not yet check every create field. See Known limits of the Docker options.

Set browser session limits

Two settings limit how long a browser stays signed in. Set them in the server environment.

  • CH_BROWSER_SESSION_IDLE_TIMEOUT signs a browser out after no activity. The default is 1h. Typing in an attached terminal counts as activity.
  • CH_BROWSER_SESSION_MAX_LIFETIME refuses a refresh after this long since sign-in. The default is 12h. The user then signs in again.

Set a value to 0 to turn that limit off. Use Go duration syntax, such as 30m or 8h.

Your identity provider sets the token lifetimes. In Cognito, open the user pool, then App integration, your app client, then Edit. Under Token expiration, set these values:

  • AccessTokenValidity and IdTokenValidity set how long a bearer works.
  • RefreshTokenValidity sets how long the refresh token works.
  • TokenValidityUnits sets the unit of each value.

The pool setting names are access_token_validity, id_token_validity and refresh_token_validity.

The CodeHerder limits apply on top of the Cognito values. The shorter limit wins. For a hard cap, set RefreshTokenValidity no longer than CH_BROWSER_SESSION_MAX_LIFETIME. The server-side lifetime check protects a left-open browser. It does not stop someone who already holds a refresh token, because the identity provider accepts it directly until it expires.

Set database query time limits

CodeHerder limits how long one database statement can run. Each connection pool has its own limit. Set them in the server environment.

  • CH_READ_STATEMENT_TIMEOUT_MS limits reads that serve requests. The default is 30000 (30 seconds).
  • CH_WRITE_STATEMENT_TIMEOUT_MS limits writes. The default is 120000 (2 minutes).
  • CH_ANALYTICS_STATEMENT_TIMEOUT_MS limits report scans. The default is 300000 (5 minutes).
  • CH_READ_WORK_MEM_MB sets work_mem for reads. The default is 8. Use 0 for the server default.
  • CH_WRITE_WORK_MEM_MB sets work_mem for writes. The default is 0, the server default.
  • CH_ANALYTICS_WORK_MEM_MB sets work_mem for report scans. The default is 64.

Production requires a value above 0. With CH_ENV=production, the server does not start if a limit is 0. Outside production, 0 turns that limit off. PostgreSQL cancels a statement that runs past its limit. The request then fails with internal_error (HTTP 500).

Migrations are exempt. They can run as long as they need. You do not need a role-level or instance-level statement_timeout on your PostgreSQL server. Do not set one, because it would also cut migrations.

Report a security flaw

To report a flaw in CodeHerder, email the CodeHerder security contact. The CodeHerder website lists it in /.well-known/security.txt, and your support agreement names it too. Do not open a public issue. You need no account. For an encrypted report, ask for a PGP key in a first email with no details.

Your server answers GET /.well-known/security.txt with no token. Set CH_SECURITY_CONTACT to your own security team, and CH_SECURITY_POLICY_URL to your own policy page. With no contact set, the route answers 404. See Reporting a security vulnerability.

Errors

Status Meaning
401 No token, or an invalid or expired one.
403 feature_not_entitled Your workspace’s plan doesn’t include self_host.
403 forbidden_scope A download key was used on a route other than the manifest or artifact endpoints.
404 not_found The filename doesn’t match a build in that release’s manifest, or the release is older than 90 days, or the version doesn’t exist.
503 feature_disabled This CodeHerder deployment doesn’t have self-host downloads configured.

Each completed download is recorded on your workspace’s activity feed as “server bundle downloaded” — see Activity feeds.

Last updated

CodeHerder

Round up your herd.

Bring every human and every agent onto one table. Watch the work move. Costs update as it happens.

Try "pricing", "connect a device", or "who reviews the code"

↑↓ move · ↵ open · esc close