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.
- Check you have access to the
self_hostentitlement, then download a build. To run Core, build from source instead. - Verify the signature and checksum of a download. Install the licence before first boot.
- Escrow the four keys before first boot.
- Point the server at your database and use
sslmode=verify-full. - Choose your AWS region.
- Set the public origins.
- Set up mail.
- 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.
- Harden the host. Read what the server enforces and what you own.
- Connect your identity provider, close self-service signup and create the first account.
- Back up and escrow, and rehearse a restore.
- Connect your CLI and devices and pick an execution mode. See Executor isolation.
- Read where your data goes in Self-hosted data residency.
- Agree who owns credentials, devices and contacts, and sign the acceptance record in Self-hosted responsibilities and launch acceptance.
- Run the checks in Verify Cognito before go-live.
- Run the Self-hosted acceptance journey in each execution mode you use.
- 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_REGIONis a device knob for the Bedrock route. It does not inheritCH_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 isus-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_unavailableormail_unavailable.db:ok,writer_saturatedorunavailable.identity:off,ok,no_keysorinit_failed. See Connect your identity provider.mail:ses,fakeorinit_failed.init_failedmeans 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_rowsornot_checked. The status stays 200 when the value is notok. 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
- Back up the database. See Upgrade to a new release.
- Upgrade the server.
- Wait for a healthy
/v1/healthanswer. - Let the devices update. They update themselves.
- Run
ch self-updateon 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.
- 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.
- A vulnerability with signs of use is a security incident. The vendor notification clock in “Self-hosted support and incident response” applies instead.
- The notice names the fixed version, the affected versions, the severity, what to do now, and the
CH_DEVICE_PATCH_MIN_VERSIONvalue to set.- 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.
- 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.
- 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. - Query the manifest again to confirm the current release. See Read the manifest above. Read its
migrationsobject. - Download and verify the new build. See Download a build and Verify the signature, then the checksum above.
- 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. - 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--dbvalue. 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/healthreportsmigrating, wait. Do not stop the server with SIGTERM. - 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.
-
Stop the new server.
-
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 -
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.
-
Install the prior webapp tarball from the same release, if you serve the dashboard yourself.
-
Start the prior binary with the same four keys and the restored
--dbvalue. -
Confirm the prior version answers. Run
curl https://<your-server>/v1/version, orch 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
Refererrequest header and the request URI: the query string is stripped from both, not just a named list of parameters — this covers an OAuthcode/statepair and any other secret query value. - Response headers:
Set-Cookie,Sec-WebSocket-Protocol. - The
Locationresponse 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(default2000) andCH_CALLER_RATE_PER_MIN(default1200) set the limit for each signed-in caller.CH_WORKSPACE_INFLIGHT_CAPsets how many requests one workspace may have in flight at once. The default is twiceCH_READ_POOL, so it grows when you raise the reader pool.0turns it off.CH_AUTH_FAILURE_BURST(default100) andCH_AUTH_FAILURE_RATE_PER_MIN(default60) 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(default120) andCH_TASK_CREATE_PER_MIN(default60). 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(status409). -
Task chain depth. A fixed limit of 10 levels. Error code:
task_depth_cap_reached(status409). -
Team and workspace messages.
CH_BROADCAST_SEND_BURST(default30) andCH_BROADCAST_SEND_PER_MIN(default10). Error code:broadcast_rate_limited. -
Session wakes from those messages.
CH_WAKE_FANOUT_BURST(default500) andCH_WAKE_FANOUT_PER_MIN(default200). Over the cap, the server keeps the message and wakes no session. The send still succeeds. -
Attachment downloads.
CH_DOWNLOAD_BURST(default600) andCH_DOWNLOAD_PER_MIN(default300) count requests. Error code:attachment_download_rate_limited.CH_DOWNLOAD_MIB_BURST(default4096) andCH_DOWNLOAD_MIB_PER_MIN(default1024) count megabytes. Error code:attachment_download_bytes_limited. -
Publication imports.
CH_IMPORT_BURST(default20) andCH_IMPORT_PER_MIN(default10) count import requests. Error code:import_rate_limited.CH_IMPORT_RECORD_BURST(default20000) andCH_IMPORT_RECORDS_PER_MIN(default5000) 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(default8) caps the live attach sockets one member holds. Over it, the server answers429with error codetoo_many_attach_sockets.CH_ATTACH_SOCKET_CAP(default256) caps all live attach sockets together. Over it, the server answers503with error codeattach_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-tunnelis a WebSocket. AllowUpgradeand 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/versionfor 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. KeepCookie,Set-Cookie,Originand theX-CH-CSRFheader. 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:
/healthand/v1/healthfor your load balancer. - Live connections:
/v1/wsand/v1/sessions/{id}/attach. The bearer token travels inSec-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
429responses and theirRetry-Afterheader through unchanged. - Make the last proxy replace
X-Forwarded-Forwith the real client address. An AWS load balancer or WAF appends to it, so overwrite it. - Do not rely on
X-Real-IPorTrue-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-Policywithscript-src 'self', and HSTS,nosniffandReferrer-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 andnosniff.
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 showsignupEnabled: false. That caller is not an instance operator. Operator rights come only fromCH_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.shmakes 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_FILEon the proxy. SetCH_STAGE_DOCKER_TOKEN_FILEon 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_TIMEOUTsigns a browser out after no activity. The default is1h. Typing in an attached terminal counts as activity.CH_BROWSER_SESSION_MAX_LIFETIMErefuses a refresh after this long since sign-in. The default is12h. 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:
AccessTokenValidityandIdTokenValidityset how long a bearer works.RefreshTokenValiditysets how long the refresh token works.TokenValidityUnitssets 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_MSlimits reads that serve requests. The default is30000(30 seconds).CH_WRITE_STATEMENT_TIMEOUT_MSlimits writes. The default is120000(2 minutes).CH_ANALYTICS_STATEMENT_TIMEOUT_MSlimits report scans. The default is300000(5 minutes).CH_READ_WORK_MEM_MBsetswork_memfor reads. The default is8. Use0for the server default.CH_WRITE_WORK_MEM_MBsetswork_memfor writes. The default is0, the server default.CH_ANALYTICS_WORK_MEM_MBsetswork_memfor report scans. The default is64.
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.
Related guides
-
Recover from a half-applied self-host upgrade — finish a stopped download, startup, webapp deployment, or device update
-
Self-hosted data residency — where data lives and every flow that leaves your AWS account
-
Integrations — read-only GitHub and GitLab tokens; merge uses the device’s
gh/glab. The GitLabapiscope is optional, for webhook registration only -
Inbound webhooks — which signing scheme to pick for each sender
-
Plans and limits — what the Enterprise plan includes, and how to check your entitlements
-
Verify a self-hosted host — commands that compare the host with the reference
-
Self-hosted licence expiry and renewal — the expiry warning to alert on, what stops after a lapse, and how to renew
-
Custom branding — set your deployment’s own name, colours, and login screen
-
Credentials and profiles — sign in, configure
CH_ADDR, bootstrap the first account, and revoke credentials that do not expire -
How do I add a device? — register a device against your self-hosted server
-
Isolating agent runs on a device — what an agent can reach, with and without
CH_AGENT_USER -
Self-hosted device isolation — each mode’s boundary and what workspaces on one device share
-
Who can run code on your device — what a compromised server or a workspace admin can run on a linked device
-
What a shared device shares — one credential pool per device
-
Hardening a self-hosted server — required production values, a sandboxed systemd unit, drop-ins and a proxy config
-
Self-hosted logs — log formats, what to redact, and a tested way to ship logs off the host
-
Self-hosted retention and data subject requests — the retention schedule per data class, a DSAR procedure, and backup residue
-
Self-hosted Cognito sign-in — grant the lifecycle permissions, alert on a denied revoke, and turn on threat protection
-
Verify Cognito before go-live — ten checks on your own pool: permissions, MFA, login, invitations, SCIM and deprovisioning
-
Self-hosted acceptance journey — the first account to a finished task in each execution mode, with a blank evidence record
-
Administrator briefing — visibility, inheritance and execution-mode trust, with an acknowledgement to sign
-
Self-hosted operator access and changes — no vendor access, operator permissions, approvals and change records
-
Self-hosted responsibilities and launch acceptance — who owns credentials, devices and contacts, and the acceptance record to sign before live work
-
Diagnose device tunnel failures — find why a device keeps going offline, and hand off to the support contact with the bundle
-
Release a provisioning breaker — release a device that stays ineligible for a task
-
Self-hosted diagnosis exercise — rehearse both runbooks on a staged fault, with a blank evidence record
-
Self-hosted support bundle — send setting names, versions, health checks and counters when you ask for help
-
Self-hosted KMS keys and attachments bucket — create the secret-store key and an encrypted attachments bucket in your own account
-
AI provider outages — what a rate limit, quota or outage looks like on each route, how retries are bounded, and what to do
-
Self-hosted support and incident response — who runs an incident, severity levels, notice times, and tests for paging and a leaked key
-
Replacing a compromised self-hosted server — isolate a suspect host, preserve evidence, build a clean replacement and rotate credentials
-
Self-hosted synthetic checks — run one command on a schedule to prove sign-in, mail, secrets, devices and updates work
-
Self-hosted exit and data preservation — export each workspace, check the export against your database, and keep readable copies
-
Self-hosted departure checklist — retire identity, devices, integrations and backups in order, with a blank rehearsal record
-
Self-hosted log retention and access — set readers and retention for each log class, audit receiver and device
-
Monitor background workers — the endpoint to poll and when to page when a worker is stuck
-
Alarm on trace export failures — the failure counters, the backlog signal, how long an undelivered event survives, and an alarm recipe that escalates to the support contact
-
Self-hosted service objectives — measure each journey and feed, and set your targets from the ranges CodeHerder states
-
When GitHub or GitLab is down — what a git-host outage looks like, why no stage completes falsely, and what to do after recovery
-
Self-hosted key custody — keep the keys outside the host, production key settings, and KMS key recovery
-
Self-hosted backup and recovery — escrow the four keys, back up the database and attachments, and restore
-
Rotating self-hosted at-rest keys — set a previous key, reseal stored secrets, and drop the old key
-
Self-hosted launch workload — the load to plan and test for, with a source for each number
-
Sizing a self-hosted deployment — starting sizes for the app host, database, and devices
-
Self-hosted device disk growth — measure device disk use, set an alert, and clean what has no bound
-
Self-hosted database encryption and TLS — your own KMS key for the database, and a verified TLS connection string
-
Verify the audit export — check that your copy of the audit webhook export is complete and unaltered, after pruning and after a workspace is deleted
-
Mail, spend and security detections — SES alarms, infrastructure budget alarms and a SIEM detection matrix
-
Self-hosted infrastructure — an inventory template, every outbound destination, and the AWS permissions the server needs
-
Self-hosted outbound firewall — what to allow, how to build the rule, and how to test it
-
Contain an incident — pause, freeze, revoke a person’s access, and when to stop the server instead
-
Activity feeds — where a completed download shows up
-
Updating the CLI — what the behind and ahead version-skew notices mean
-
Reporting a security vulnerability — email the vendor privately; your server also publishes
/.well-known/security.txt
Last updated