Self-hosting
Running a Coder server with its database, sign-in, apps and runners.
A self-hosted Coder server holds the agents and their platform apps, every task with its output, and the dashboard. Platforms deliver events to it, runners report back to it, and the CLI and SDK reach it with --server. The hosted service at coder.wular.ai is the default.
Deploy
One server, one database, any number of agents, running on Bun. The server is configured through its environment.
Required variables carry a *.
| Variable | Type | Default | Purpose |
|---|---|---|---|
PUBLIC_URL* |
url without a path | The address platforms and browsers reach the server at. Written into every app as its webhook and callback address, and the only origin sign-in trusts. Inferred on Vercel; serve defaults to http://localhost:<port> |
|
DATABASE_URL or POSTGRES_URL |
url | Postgres. Everything lives in a coder schema, so the database can be shared with other apps. Run the database setup command below before the first start and after upgrades. Without it coder server serve runs the local dashboard; --memory selects a throwaway test server. Statements are prepared on each connection, so a query costs one round trip; add ?prepare=false to the URL for a pooler that cannot keep prepared statements |
|
SERVER_ENCRYPTION_KEY* |
base64 32 bytes | Encrypts app keys, tokens, and engine credentials at rest (openssl rand -base64 32), and derives the session cookie key and the sign-in client key with HKDF. Required with Postgres |
|
AUTH_WULAR_URL |
url | https://auth.wular.ai |
Wular Auth OpenID issuer, the only identity provider, including for a self-hosted Wular Auth deployment |
RUNNER |
local | local-docker | vercel-sandbox | github-actions | http |
local, vercel-sandbox on Vercel |
Where tasks execute. local and local-docker run them on the server's own machine and work with the memory store, and are refused on Vercel. The other three run them elsewhere and need Postgres. Members' own machines add to this (Runners) |
RUNNER_CONFIG |
JSON | {} |
Options for the chosen runner (Runners). Required for http and github-actions |
SERVER_ENCRYPTION_KEY_PREVIOUS |
base64 32 bytes | The old key, accepted until coder server rotate-key has re-sealed everything. While set, its sign-in key stays published and its session cookies still open, so rotation keeps everyone signed in |
|
PORT |
number | 8787, or the port of a localhost PUBLIC_URL |
Listen port |
HOST |
address | 127.0.0.1 without a database, else all |
Listen address. A local server listens on loopback only (Modes) |
ADMIN_TOKEN |
string | minted and printed per run | Local and memory servers. The bearer the CLI and dashboard use, accepted from loopback only. Ignored with Postgres |
CODER_CACHE_HOME |
path | OS cache directory for wular-coder |
Archived tasks, flows and usage |
WORK_DIR |
path | ~/.coder/state/global/server-tasks |
Where the local runner keeps each task's work folder: its working directory, or its checkout with a repository. The folder stays across turns and goes with task delete |
SERVER_NAME |
string | Coder |
What the dashboard calls this server, in its sidebar server menu |
REVIEW_ASSETS |
path or URL | unset | Themed images for posted pull request reviews (sev-* and btn-* SVGs, dark and light); a path resolves against PUBLIC_URL. Unset posts plain Markdown reviews |
ISSUES_URL |
URL | https://github.com/muzam1l/coder/issues/new |
Where the dashboard sends bug reports from its Report issue button |
SERVER_LIMITS |
JSON | {} |
Request rates (Limits and monitoring) |
USAGE_LIMITS |
JSON | unset | Limits per workspace (Usage limits) |
PLANS_URL |
URL or path | unset | Where limit notices and refusals link for more usage; a path resolves against PUBLIC_URL |
CLAUDE_SUBSCRIPTIONS |
1 |
off | Offer [Sign in with Claude] for personal credentials |
CODEX_SUBSCRIPTIONS |
1 |
off | Offer [Sign in with Codex] for personal credentials |
MAX_TASKS |
number | unset | Optional deployment-wide runner slot cap |
MAX_QUEUED |
number | 1000 |
Tasks a workspace may have waiting |
TASK_TIMEOUT |
ms | 1800000 (30 minutes) |
Longest a task may run |
TASK_STALL |
ms | 300000 (5 minutes) |
Silence before the runner is asked whether the task is still alive |
TASK_ATTEMPTS |
number | 2 |
Times a runner start is retried |
SERVER_INTEGRATIONS |
comma-separated ids | every platform | The platforms this server offers, from github, slack and linear. Others get no webhook, catalog entry or app creation. An unknown id stops the server |
SERVER_OPERATORS |
comma-separated Wular Auth user ids | none | Postgres servers. Who may open the operator area. Only this variable grants it |
Settings, sign-in, pairings and live tasks live in ~/.coder (CODER_HOME). Archived tasks, flows and usage live in the OS cache directory, ~/Library/Caches/wular-coder on macOS, ~/.cache/wular-coder on Linux (CODER_CACHE_HOME overrides). Nothing is ever pruned.
Modes
| Started with | Mode |
|---|---|
DATABASE_URL |
Cloud: sign-in with Wular Auth, workspaces, Postgres |
--memory |
Test server: everything in memory, the admin token, and a "not for production" warning |
| neither | Local: the dashboard for this machine's CLI, on loopback only |
A memory server refuses to start on Vercel. Without a database, a connection from another machine reaches only the platform endpoints (/hooks/, /create/, /install/, /connect/), so --host 0.0.0.0 lets platforms reach a memory server directly while the dashboard and /admin stay on this machine.
Vercel
The fastest way. The Vercel template has a Deploy button that creates the project with Postgres and asks only for SERVER_ENCRYPTION_KEY. Wular Auth sign-in is enabled automatically. PUBLIC_URL is the deployment's own address.
Other serverless hosts
Mount server.handler(env) from the SDK as the request handler in a function that runs on Bun. Pass the platform's background-work hook as the second argument to keep task startup and other background work alive after the response goes out.
import { server } from '@wular/coder';
import { waitUntil } from '@vercel/functions';
const handler = server.handler(process.env);
export const GET = (request: Request) => handler(request, waitUntil);
export const POST = GET;
Run await handler.scheduled() from a scheduled job to sweep expired state across every workspace, renew platform subscriptions, start queued tasks, and deliver pending messages. Call await handler.close() when the host shuts down.
DATABASE_POOL_MAX sets the process pool limit, defaulting to 10 connections on a persistent host and 2 on Vercel. DATABASE_IDLE_TIMEOUT sets idle expiry in seconds, defaulting to 240 and 30 respectively. Set these before creating a handler; handlers sharing a database share the first pool's settings.
Completed tasks, their logs and usage remain until a task is explicitly deleted. Sandbox snapshots remain until replaced or deleted. Expiring deliveries, engine sign-ins and chat state are cleaned by the sweep.
Database setup
Before the first start and each upgrade, set DATABASE_URL and run npx --yes --package=drizzle-kit@0.31.11 --package=drizzle-orm@0.45.2 --package=postgres@3.4.9 drizzle-kit push --config=./node_modules/@wular/coder/dist/schema.config.js from the directory where Coder is installed. Run it as a deploy step for serverless hosts.
A plain host
Install Bun and @wular/coder, set the variables above, set up the database as above, then run coder server serve. The command starts the server under Bun, listens on PORT (default 8787), and answers /health. Keep it alive with whatever supervises processes on that host. It listens on every interface, as containers and load balancers need; behind a proxy on the same machine, --host 127.0.0.1 keeps it private. From code, server.serve() and server.rotateKey() do the same.
After the first start
Open PUBLIC_URL and sign in with Wular Auth. Workspaces and roles come from Wular Auth.
Add an engine credential, or the first task will only reply with a link asking for one.
Create the built-in agent's apps once per server with coder server app create <integration>, such as github or linear, run where the server's DATABASE_URL and PUBLIC_URL are set. Each opens a one-time page that posts the app's manifest to the platform; --org <org> creates the GitHub App under an organization the caller administers. These apps are public and shared by every workspace, and the app's credentials go straight to the server. Linear apps need public distribution turned on before other workspaces can add them.
Apps and installs
An agent's handle on a platform is its app. The built-in agent coder uses shared apps on GitHub and Linear; create its Slack app in the Slack workspace from # Agents → coder → (Platforms) by pasting a Slack configuration refresh token. A custom agent gets its own app from # Agents → agent → (Platforms) in the dashboard, under the member's own GitHub account or an organization whose apps they manage. Private apps install only on their owner; an app made public on its platform installs on any account.
Installs start from the dashboard. [Connect GitHub] adds the shared GitHub app, and # Agents → agent → (Platforms) creates or installs an agent's workspace app. The server binds an install to the workspace that started it with a one-time, expiring state. On GitHub it also requires the installer to authorize the app and checks that the installation appears in their own GET /user/installations; on Slack it uses [Add to Slack] OAuth keyed by team. An install started on GitHub asks the installer to sign in and binds only if they are a member of the app's workspace. An install belongs to exactly one workspace, and webhooks route by installation. Removing or suspending an install on the platform stops it.
Operators set up the shared apps in the dashboard's Operator area at /dash/<workspace>/operator, one per platform, named SERVER_NAME or Wular unless they pick a name, and every operator action lands in its audit log. Entering it asks for a fresh Wular Auth sign-in and lasts an hour, or 15 minutes without activity; on local and memory servers the admin token holder is the operator.
Each task gets the narrowest platform token: on GitHub an installation token for the event's repository with only the permissions its tools need, on Slack the install's bot token. Each turn's git pushes with the installation covering the repository and commits as the app's bot. A member who approves acting as them on their GitHub connection gets the turn's pushes and commits under their own GitHub account instead. A GitHub App also asks for Checks: Read and write, for review checks; existing apps list it under [Update on GitHub], and each installation accepts it once.
Credentials
Credentials are subscriptions and API keys. A subscription is a member's own Claude or Codex sign-in, one per engine, offered when CLAUDE_SUBSCRIPTIONS=1 or CODEX_SUBSCRIPTIONS=1 is set; it runs the official claude setup-token or codex login --device-auth on the server's runner, or takes pasted claude setup-token output. An API key is saved as its environment variable, one per variable in each scope: personal for any member, workspace-wide for owners and admins.
Each engine uses its subscription, else its key (ANTHROPIC_API_KEY for Claude, OPENAI_API_KEY for Codex), the requester's own before the workspace's; a custom model uses the variable it names. Tasks with no known requester use the workspace's only, and without a credential the agent replies with a link to the Credentials page.
Manage them under # Settings → (Credentials) or with coder credentials add, list, login and remove, which read secrets from the environment or stdin and never print them.
Tasks
A task starts from the dashboard, coder task run --server, or a platform event. Its credential is the requester's own, then the workspace's; a shared platform conversation, such as a channel thread or a pull request, uses only the workspace's. Without one it is refused with a link that connects it. Each running task holds a runner slot, including tasks waiting for a reply or approval. USAGE_LIMITS caps a workspace's tasks on the server's own runner, and MAX_TASKS caps slots across all instances. TASK_STALL, TASK_TIMEOUT and TASK_ATTEMPTS bound each task.
A task is a conversation: steer it or ask it a question while it runs, and continue it once it has finished. Its output streams over Server-Sent Events, and a repo task's result includes the diff of its changes. The CLI's task commands take --server to do the same as the dashboard. A repo's own .coder/config.json can only narrow the workspace's config.
Only the requester of a 1:1 DM task and the workspace's owners and admins see it, in the dashboard, CLI, SDK and API. Its requester steers it, and admins can view, stop and delete it. When the requester hasn't connected that platform account, only admins see it.
Sign-in and workspaces
Wular Auth is the only identity provider. Coder is an OpenID Connect client with no accounts of its own and signs its requests with a key derived from SERVER_ENCRYPTION_KEY, so there is no client secret to register. Sessions refresh themselves as Wular Auth's tokens expire, and signing out also ends the Wular Auth session.
Workspaces come from Wular Auth, where members and roles are managed; the members page links there. Wular Auth sends an account in no workspace through its onboarding before it signs them in to Coder. Apps, installations, tasks, and credentials all belong to the selected workspace. Every member may see and use everything in the workspace except another member's DM tasks; only owners and admins change workspace credentials.
From a terminal, coder auth login signs in with Wular Auth (--device shows a code to approve elsewhere) and keeps the session in ~/.coder/session.json. Its tokens are issued for this server only. --workspace <slug> picks a workspace and --server <url> another instance. Servers must use HTTPS unless they are loopback, and the CLI confirms a new origin the first time it sees it (--yes acknowledges it in scripts).
Anyone the platform lets write where they ask starts and steers tasks: on GitHub an organization member with write access to the repository or a bot's pull request from a branch of it, on Slack and Linear a full member, never a guest. Installing an app connects the installer.
Runners
What members and admins do with runners, such as pairing their own machine and choosing defaults, is in Runners. This section is the operator's side.
RUNNER_CONFIG is a JSON object read by the chosen runner. Every field is optional unless marked.
| Runner | Field | Default | Purpose |
|---|---|---|---|
local |
none | ||
local-docker |
image |
the runner image, pinned by digest | Image the task runs in |
memory |
4g |
--memory |
|
cpus |
2 |
--cpus |
|
pidsLimit |
512 |
--pids-limit |
|
network |
bridge |
--network |
|
vercel-sandbox |
image |
the SDK's default image | Image the task starts from |
token |
deployment OIDC | Vercel access token for Sandbox; install @vercel/sandbox in the adapter app |
|
team |
VERCEL_TEAM_ID |
Team ID for access-token authentication | |
projectId |
VERCEL_PROJECT_ID |
Project ID for access-token authentication | |
region |
the database's region | Sandbox region; inferred from an AWS region in the database host, else Vercel's default | |
timeoutMs |
1800000 |
Sandbox lifetime | |
vcpus |
1 |
Sandbox size | |
github-actions |
token (required) |
GitHub token that may dispatch the workflow and read its status | |
repo |
the event's repository | owner/name holding the workflow |
|
workflow |
coder-runner.yml |
Workflow file to dispatch (coder server workflow prints it) |
|
http |
url (required) |
The controller's base URL; it implements POST /start, POST /messages/<handle>, GET /status/<handle>, POST /stop/<handle> |
|
secret (required) |
Shared secret that signs every request over method, path, body, timestamp, and nonce | ||
headers |
{} |
Extra headers sent with every request |
local-docker and github-actions run in the runner image, which holds the engines and tools. A custom image needs node, bun, git, curl, tar, claude, codex and github-mcp-server.
local-docker, vercel-sandbox and github-actions download this server's built runner from its public, immutable /runner/<hash>.tgz URL and check it against that hash. Repository snapshots are specific to that build.
On vercel-sandbox each task uses the universal image's engines without their own sandbox, and its machine stops when the task ends. Repository snapshots hold the checkout and its installed dependencies. The default is 1 CPU and 2 GB memory; vcpus selects a larger machine.
Access-token authentication needs a token, team and project. # Settings → (Runners) lists the token's teams and projects; an empty team is the account's default team, and an empty project is vercel-sandbox-default-project, created when missing. For the server's own runner the adapter app can supply VERCEL_TEAM_ID and VERCEL_PROJECT_ID. The firewall injects engine API keys into provider requests, and the task sees a placeholder. Below auto, a task's machine reaches only this server, its engine, integrations, connections, MCP servers and network.allowedHosts.
For GitHub Actions, adding the runner commits .github/workflows/coder-runner.yml; coder server workflow prints the same file. Each run, named Coder runner <task ID>, runs in the dispatched image, checks the runner package against hash, then trades claim, a one-time code valid for 10 minutes, for the task's token, proving with its GitHub OIDC token that it is the dispatched run. A dispatch refused with HTTP 422 means the file is out of date. Anyone with write access to the repository can change what a run does. The token needs Actions read and write; a classic token for a private repository needs repo.
Every runner sends the task's log to the server while it runs, and the dashboard shows the stored log.
Security
The server only verifies, dedupes, and dispatches; agents run in the runner with one checkout, one short-lived platform token, and one engine credential. access is enforced by the engine sandbox, on vercel-sandbox by the machine and its firewall, tools by the app's permissions and the MCP allowlist. App credentials are encrypted at rest.
Usage limits
USAGE_LIMITS is a JSON object of limits for each workspace. Each value is a positive integer; an unset key has no limit.
| Key | Limit |
|---|---|
cpuMinutes |
CPU minutes a month (UTC) on the server's own runner; one is 1 vCPU for 1 minute. Tasks on a workspace's own runners never count |
maxConcurrentTasks |
Running tasks on the server's own runner, including tasks waiting for a reply or approval |
maxRunners |
Most runners a workspace registers, workspace and personal together |
Once a workspace has used its CPU minutes, new tasks on the server's runner are refused and running ones stop. The dashboard shows notices at half and all of the minutes, linking to PLANS_URL when set. A host sets each workspace's limits with server.handler(env, { usageLimits: organizationId => limits }); its answer replaces USAGE_LIMITS, and {} means no limits.
For example, USAGE_LIMITS='{"cpuMinutes":300,"maxConcurrentTasks":10,"maxRunners":10}'.
Limits and monitoring
SERVER_LIMITS is a JSON object stored under limits in the server config. Each value is a positive integer. Unset keys use these defaults.
| Config key | Default | Purpose |
|---|---|---|
limits.principalPerMinute |
300 |
Requests per minute for each signed-in user, the memory server's admin token, or an authenticated runner |
limits.ipPerMinute |
60 |
Requests per minute for each unauthenticated IP |
limits.authPerMinute |
20 |
Sign-in, token and login requests per minute for each IP, on top of ipPerMinute |
limits.registerPerMinute |
10 |
Runner registrations per minute for each IP; includes pairing and returning-runner heartbeats |
limits.pairingPerMinute |
5 |
Pairing tokens per minute for each principal at /admin/runners/pair |
For example, SERVER_LIMITS='{"principalPerMinute":300,"ipPerMinute":60,"authPerMinute":20,"registerPerMinute":10,"pairingPerMinute":5}' sets every default explicitly. All /admin/*, /runners/*, sign-in and pairing requests use the same token-bucket implementation. A bucket starts full and refills continuously over a minute. A refused request returns 429, a readable JSON error and Retry-After in seconds. Buckets and counters belong to each server process and reset on restart; serverless instances keep their own buckets.
Local hosts use the connection's peer IP. Vercel uses the IP supplied by its proxy. Other fetch hosts without a peer IP share one unauthenticated bucket; place an IP-aware limiter at their ingress. Client-supplied forwarding headers never select a bucket on a local host. Behind a reverse proxy, the peer bucket belongs to that proxy.
Running tasks and tasks waiting for approval hold slots. Further tasks remain queued, with “Waiting for a task slot” in their status, until a slot opens. MAX_TASKS, when set, also bounds runner slots across the deployment. The concurrent cap applies to tasks admitted by the server; standalone CLI tasks do not pass through the server, but active CLI tasks on a local server's machine are counted before it starts another task.
GET /health needs no sign-in and returns { ok }: 200 when the server and its database answer, 503 when they do not. Local mode keeps its usual loopback access restriction.
GET /admin/metrics requires an owner or admin of the selected workspace, or the memory server's admin token. It returns tasks with counts for every status among tasks created in the last 24 hours, queueDepth for all queued tasks, runnersOnline, requests, and rateLimitRejections. Task and runner counts belong to the selected workspace; request and rejection counters cover this server process since startup.