New: AI spend limits now cover the agent builder and the project planner →
Product
AI Teammates
Solutions
Resources
PricingEnterprise
Start free Book a demo Log in
Run it yourself

Self-hosting

Run TaskCrew on your own server with Docker Compose: HTTPS, monitoring, backups and restores, upgrades and secrets.

How to run TaskCrew on your own server with Docker Compose: a first try-out, HTTPS, monitoring, backups and restores, upgrades and secrets.

Quick start (Docker)#

Requirements: Docker Desktop (or Docker Engine + Compose v2).

docker compose up -d --build    # first build takes a few minutes; works as is for a local try-out
open http://localhost:3000      # sign up → create a workspace → create an AI teammate
docker compose down -v          # try-out only: removes everything, the database and uploads too (never on a server)

Settings live in one file. deploy.env.example holds working defaults; copy it to deploy.env (never committed) and change what you need: secrets, your domain (WEB_ORIGIN, PUBLIC_API_URL), OPENROUTER_API_KEY for AI, email, storage, billing. Then run docker compose up -d --build again. The dev .env is not used by the stack. Add --rmi all to down to remove the built images as well.

Running it for real: read on: HTTPS, daily database backups to S3 (BACKUP_S3_BUCKET), restores, upgrades and secrets.

On a server, with HTTPS: set TASKCREW_WEB_HOST, TASKCREW_API_HOST and TASKCREW_ACME_EMAIL in deploy.env, with WEB_ORIGIN / PUBLIC_API_URL at their https:// addresses and COOKIE_SECURE=true. Point both names at the server, open ports 80 and 443, and run docker compose --profile https up -d --build. Caddy (infra/caddy/Caddyfile) gets and renews the certificates. The web app and the API listen on 127.0.0.1 only, so nothing reaches them except through it. Production settings are checked at start: the API refuses example secrets, insecure cookies on https and Dodo's test mode.

Services: web :3000 · api :4000 · runner :4001 (health) · postgres · docker-proxy · migrate (one-shot) · sandbox-image (builds the per-run sandbox image) · caddy :80/:443 (profile https).

Never run docker compose down -v on a server#

-v deletes the Docker volumes: the database (pgdata) and any uploads kept on disk. It is only for a local try-out. To stop the stack, use docker compose down (or stop); the data stays.

HTTPS#

Set TASKCREW_WEB_HOST, TASKCREW_API_HOST and TASKCREW_ACME_EMAIL in deploy.env, with:

  • WEB_ORIGIN and PUBLIC_API_URL at their https:// addresses;
  • COOKIE_SECURE=true;
  • COOKIE_DOMAIN set to the domain they share, when they are different hosts.

Then point both names at the server, open ports 80 and 443 (and nothing else), and run:

docker compose --profile https up -d --build

Caddy (infra/caddy/Caddyfile) gets and renews Let's Encrypt certificates and adds HSTS. The web app and the API listen on 127.0.0.1 only.

The API refuses to start once it is reached beyond localhost if it has:

  • an example, short or reused secret;
  • COOKIE_SECURE=false on https;
  • billing in Dodo's test mode.

Sign in with Google#

"Continue with Google" shows on the sign-in and sign-up pages once GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set in deploy.env (both or neither):

  1. In Google Cloud, open APIs & Services → OAuth consent screen and set up the app (External, with your app's name, support email and links to /legal/privacy and /legal/terms).
  2. In Credentials, create an OAuth client ID of type "Web application" with the authorized redirect URI <PUBLIC_API_URL>/api/auth/google/callback, e.g. https://api.example.com/api/auth/google/callback.
  3. Put its client ID and secret in deploy.env and restart the stack.

Someone whose Google account has the email of an existing account signs in to it. If that account's email was never confirmed, its password stops working (Google confirmed the address; whoever signed up with it first may not own it). New emails get a new account without a password; "Forgot password" adds one.

Monitoring#

Alerts#

Set ALERT_WEBHOOK_URL to a Slack incoming webhook (Microsoft Teams and Google Chat workflows that take { "text" } work too). The people who run the server then hear about:

  • API: any 500 (one message per route, without ids), file storage that can't be reached, crashes and unhandled errors.
  • Runner:
    • runs failing for the server's reasons (sandbox, internal error, AI model unreachable);
    • a run that crashed;
    • the run queue or the schedule poll failing;
    • crashes.
  • Backups: a failed dump or upload, or no BACKUP_S3_BUCKET.

The same problem is posted at most once every 15 minutes, and each process sends at most 30 an hour.

Uptime#

An outside service, for example Better Stack, UptimeRobot or Pingdom, should check every minute and page someone:

CheckHealthy
https://<api host>/api/health200; it runs SELECT 1 against the database (503 while the API restarts)
https://<web host>/login200
TLS certificate of both hostsmore than 14 days left (Caddy renews at 30)

The runner has no public port. Its health (/health on 4001, degraded when sandboxes can't start) is used by Docker's healthcheck: docker compose ps shows it.

Logs#

The API and the runner write one JSON object per line in production (LOG_FORMAT=json, the default there; text for readable lines). Docker keeps at most 5 × 10 MB per service. To keep logs longer or search them, ship them with your provider's agent (Datadog, Grafana Loki, Better Stack) from the Docker json-file logs.

docker compose logs -f api runner          # live
docker compose logs --since 1h api | grep '"level":"error"'

AI spending#

Watch Settings → AI usage per workspace. AI_DEFAULT_MONTHLY_CAP_USD, AI_GLOBAL_MONTHLY_CAP_USD and AI_DISABLED limit the whole server; set the global limit below what your OpenRouter account allows, and give the account its own limit and an alert at OpenRouter too.

Abuse#

Sign-up is open, so expect some spam and phishing attempts.

  • Built-in limits:

    • New accounts must confirm their email before they send invites, publish public links or forms, or use AI.
    • One account owns at most MAX_OWNED_WORKSPACES workspaces (10).
    • Sign-in, sign-up, forms and AI are rate-limited.
    • With TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY set, sign-up and public forms show Cloudflare Turnstile. It mostly passes by itself. If Cloudflare can't be reached, people are let in and the API logs it.
  • Suspending: when someone reports a page or account (abuse@ on the public pages), suspend it:

    docker compose run --rm migrate node packages/db/scripts/suspend.mjs workspace <slug or id> --reason "phishing form"
    docker compose run --rm migrate node packages/db/scripts/suspend.mjs user <email or id> --reason "spam invites"
    docker compose run --rm migrate node packages/db/scripts/suspend.mjs list
    docker compose run --rm migrate node packages/db/scripts/suspend.mjs user <email> --undo
    • A suspended account: can't sign in by password or SSO; its sessions, API tokens, open pages and push devices stop at once.
    • A suspended workspace: opens for nobody (423 WORKSPACE_SUSPENDED) and leaves everyone's list; its public links and forms stop, and its queued AI runs are cancelled.
    • Nothing is deleted, and --undo reverses it. To delete data for good, see "Deleting data".

Deleting data#

People can ask to have their account or their workspace deleted, for example under GDPR's right to erasure (the privacy page says to write to TASKCREW_LEGAL_EMAIL). Check that the request comes from the account's own address or from the workspace's owner, then run:

# What would go (deletes nothing):
docker compose run --rm migrate node packages/db/scripts/purge.mjs workspace <slug or id>
# A workspace, for good: every row, and its files in storage:
docker compose run --rm migrate node packages/db/scripts/purge.mjs workspace <slug or id> --yes
# A person: erased (name, email, password, My space, notifications, tokens, memberships), and the workspaces
# only they are in:
docker compose run --rm migrate node packages/db/scripts/purge.mjs user <email or id> --with-workspaces --yes
  • Paid subscriptions: a workspace with one is refused until it is cancelled in Settings → Billing, or pass --force.
  • What a person wrote in workspaces that stay (tasks, comments) belongs to those workspaces. It stays, under "Deleted user".
  • Shared workspaces: if the person alone owns a workspace that others work in, someone else must become owner first.
  • Backups keep a copy until BACKUP_KEEP_DAYS passes. Say so in your reply to the request, and keep a record of what you deleted and when.

Backups#

The database#

The backup service dumps the database (pg_dump, custom format) to S3-compatible storage:

  • the first dump 5 minutes after it starts, then every BACKUP_INTERVAL_HOURS (24);
  • dumps older than BACKUP_KEEP_DAYS (30) are deleted.

To set it up in deploy.env:

BACKUP_S3_BUCKET=acme-taskcrew-backups   # a bucket of its own, not the uploads bucket
BACKUP_S3_PREFIX=taskcrew-db/
BACKUP_KEEP_DAYS=30

It uses the S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY settings, the same as uploads.

If BACKUP_S3_BUCKET is empty, there are no backups. The service then logs a warning on every interval, and posts it to ALERT_WEBHOOK_URL if that is set. A failed dump or upload is posted there too.

Check it:

docker compose logs backup            # "saved s3://…/taskcrew-…dump (N bytes)" once a day
docker compose run --rm backup now    # one backup now, e.g. before an upgrade
docker compose run --rm backup list   # what the bucket holds

Protect the bucket:

  • turn on versioning, and Object Lock where the provider has it, so that credentials that leak or a mistake can't delete the backups;
  • give the backup keys write access to that bucket only.

Uploads (S3)#

With STORAGE_DRIVER=s3, files are in the uploads bucket, not on the server.

  • Versioning: turn it on so that deleted or overwritten files can be brought back.
  • Lifecycle rule: add one that removes noncurrent versions after, for example, 30 days.

TaskCrew never needs the old versions itself; they are only for recovery.

Restore#

Restoring replaces the whole database with the dump; everything since that dump is lost. Practise on a scratch server first, then repeat these steps once a quarter as a drill.

# 1. Pick a dump and download it to ./backups
docker compose run --rm backup list
mkdir -p backups
docker compose run --rm --user root -v "$PWD/backups:/backups" backup fetch taskcrew-db/taskcrew-20261013T030000Z.dump

# 2. Stop everything that writes to the database
docker compose stop web api runner

# 3. Replace the database with the dump
docker compose exec postgres psql -U taskcrew -d postgres \
  -c 'DROP DATABASE taskcrew WITH (FORCE)' -c 'CREATE DATABASE taskcrew'
docker compose run --rm --user root -v "$PWD/backups:/backups" --entrypoint pg_restore backup \
  --no-owner -d taskcrew /backups/taskcrew-20261013T030000Z.dump

# 4. Start again (the migrate service applies any migrations newer than the dump)
docker compose up -d

A restore drill on 2026-10-13 checked the commands above. It backed up to an S3-compatible server, fetched the dump, restored it into a new database, and compared the row counts (users, tasks, migrations, domains). They matched.

Upgrades#

git pull
docker compose run --rm backup now          # a fresh dump first
docker compose --profile https up -d --build

The migrate service applies new migrations before the API and the runner start.

The runner stops agent runs that are in progress when it is replaced. They fail as "interrupted" and can be retried. So deploy when few runs are going on.

Secrets#

SettingWhat changing it does
JWT_SECRETSigns everyone out (sessions, password-reset and email links stop working). Safe to rotate.
INTERNAL_API_TOKENChange it on the API and the runner together (one up -d).
ENCRYPTION_KEYDo not change it: stored credentials (vault entries, connector tokens, public links) become unreadable.
POSTGRES_* in docker-compose.ymlThe bundled database is reachable only inside the compose network.

Keep deploy.env out of git (it is in .gitignore), readable only by the people who run the server.

Configuration#

The settings that matter most. Set them in deploy.env; the full file is below.

VariablePurpose
OPENROUTER_API_KEYModel access for agents and the builder
JWT_SECRET, ENCRYPTION_KEYSession signing / secret encryption — set long random values in production
WEB_ORIGIN, PUBLIC_API_URLBrowser origin and the API's public URL (OAuth redirect = PUBLIC_API_URL/api/connectors/oauth/callback); email links use the first WEB_ORIGIN entry
SMTP_URL, MAIL_FROMEmail for invites, password resets, confirmations and dashboard snapshots. Without them nothing is sent: the API log shows each email's link and the app copies invite links instead
MAIL_TRANSPORT (smtp/log/memory), AUTH_RATE_LIMITSHow email leaves (memory = test outbox at /api/internal/mail); sign-in/sign-up/reset limits (false only for automated tests)
INBOUND_EMAIL_DOMAIN, INBOUND_EMAIL_SECRETEmailed responses for forms: the domain of the forms' addresses (MX at your mail provider) and the password its webhook sends to /api/inbound/email/mime (both or neither; see the security notes)
SLACK_TRANSPORT (http/memory)How chat notifications (Slack, Microsoft Teams, Google Chat) leave (memory = test outbox at /api/internal/slack, automated tests only). Notification emails go out with SMTP (they are on by default once it is set up)
MCP_CLIENT_METADATA_URLHTTPS URL of /api/oauth/client-metadata.json to enable CIMD client registration
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECTPush notifications to phones and browsers (Web Push, no Firebase): this server's key pair from npx web-push generate-vapid-keys (both or neither; keep them) and a contact (mailto: or https:). Without keys, Profile says push isn't set up; PUSH_TRANSPORT=memory is for automated tests
CONNECTOR_<KEY>_CLIENT_ID/SECRETOAuth apps for built-ins without dynamic registration (GitHub, Slack, HubSpot, Asana, Xero); CONNECTOR_GOOGLE_CLIENT_ID/SECRET is one Google Cloud OAuth client for Gmail, Google Drive, Calendar and Chat
SANDBOX_DRIVER (docker/process), SANDBOX_RUNTIME, SANDBOX_MEMORY_MB, SANDBOX_CPUSIsolation settings
AI_DEFAULT_MONTHLY_CAP_USD, AI_GLOBAL_MONTHLY_CAP_USD, AI_DISABLEDThe server's AI spending limits (API and runner): each workspace's monthly limit when its plan sets none, the monthly total for all workspaces together, and a switch that stops every AI call. Runs check the limit before each model call
RUNNER_CONCURRENCY, RUNNER_MAX_PER_WORKSPACEExecution capacity (runs waiting for an approval do not count; a runner keeps at most twice RUNNER_CONCURRENCY sandboxes)
AGENT_APPROVAL_TIMEOUT_SECONDSHow long an AI teammate's action waits for approval before it is rejected automatically (default 1800, max 7200; runner and API)
DATABASE_POOL_SIZEPostgres connections per API instance (default 20; run more API instances to scale)
LLM_REASONING_EFFORTDeepSeek reasoning effort (none…high, default low)
TASKCREW_MAP_TILE_URL, TASKCREW_MAP_ATTRIBUTION, TASKCREW_MAP_MAX_ZOOMWeb: map tiles for the Map view and "Pick on map" (default OpenStreetMap's standard tiles — fine for a small team; busy servers use their own tile server or a provider). off turns maps off. See the security notes

The settings file#

deploy.env.example holds working defaults for a local try-out. Copy it to deploy.env (never committed), change what you need, and run docker compose up -d --build again.

# TaskCrew deployment settings for `docker compose up -d --build`.
#
# This file holds working defaults for a local try-out, and docker compose always reads it.
# To change anything, copy it to `deploy.env` (never committed) and edit that copy: values in
# deploy.env win over the ones here. Then run `docker compose up -d --build` again.
#
# Clean up everything (containers, network, database and uploads): docker compose down -v
# Also remove the built images:                                    docker compose down -v --rmi all

# ── Secrets: CHANGE ALL THREE for anything but a local try-out ──
# As soon as WEB_ORIGIN or PUBLIC_API_URL is not localhost, the API and the runner refuse to start with these example
# values, with secrets under 32 characters, or with two secrets the same. Generate each with: openssl rand -base64 48
# Signs sign-in sessions.
JWT_SECRET=change-me-jwt-secret-please-0123456789
# Encrypts stored credentials (vault, connector tokens). Changing it later makes existing secrets unreadable.
ENCRYPTION_KEY=change-me-encryption-key-please-0123456789
# Shared secret between the agent runner and the API.
INTERNAL_API_TOKEN=change-me-internal-token-please-0123456789

# ── HTTPS on a server (docker compose --profile https up -d): your two host names and the address Let's Encrypt
# writes to about certificates. Point both names at the server and open ports 80 and 443. ──
TASKCREW_WEB_HOST=
TASKCREW_API_HOST=
TASKCREW_ACME_EMAIL=

# ── Addresses people use ──
# Where the web app is opened, and where browsers reach the API. With HTTPS (above), e.g.
#   WEB_ORIGIN=https://app.example.com and PUBLIC_API_URL=https://api.example.com
WEB_ORIGIN=http://localhost:3000
PUBLIC_API_URL=http://localhost:4000
# true when the site is served over https (required then: the API refuses to start otherwise).
COOKIE_SECURE=false
# Needed when the web app and the API are on different hosts: the sign-in cookie must reach both, so set the domain
# they share. Web app.example.com + API api.example.com → COOKIE_DOMAIN=example.com. Empty: same host only.
# Tip: an API host under the web host (api.app.example.com) lets you use the tighter COOKIE_DOMAIN=app.example.com.
COOKIE_DOMAIN=
# Whose X-Forwarded-For the API believes (per-IP limits, the audit log): proxies on this machine or a private network,
# like Caddy here. If the API is reached directly from the internet without a proxy, set false.
TRUST_PROXY=loopback, linklocal, uniquelocal

# ── Legal pages (/legal/terms, /privacy, /refunds, /subprocessors, /abuse): who runs this service. Have a lawyer review
# the text for your company and country before launch. ──
TASKCREW_LEGAL_ENTITY=
TASKCREW_LEGAL_ADDRESS=
TASKCREW_LEGAL_EMAIL=
TASKCREW_ABUSE_EMAIL=
# e.g. "the laws of India, with the courts of Bengaluru"
TASKCREW_LEGAL_JURISDICTION=
# Named on the subprocessors page, e.g. "Amazon Web Services (EU)", "Postmark", "Cloudflare R2".
TASKCREW_HOSTING_PROVIDER=
TASKCREW_EMAIL_PROVIDER=
TASKCREW_STORAGE_PROVIDER=
# deny: AI requests go only to model providers that neither keep nor train on them (what the privacy page says).
# If AI then fails with "No endpoints found", set allow and change the privacy page.
AI_PROVIDER_DATA_COLLECTION=deny

# ── Abuse ── Workspaces one account may own (each has its own AI credits and trial).
MAX_OWNED_WORKSPACES=10
# "Continue with Google" on the sign-in and sign-up pages (both or neither). In Google Cloud → APIs & Services →
# Credentials, create an OAuth client ID of type "Web application" with the authorized redirect URI
# <PUBLIC_API_URL>/api/auth/google/callback (e.g. https://api.example.com/api/auth/google/callback).
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Cloudflare Turnstile on sign-up and public forms (both or neither; https://dash.cloudflare.com → Turnstile).
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=

# ── AI (AI teammates, Assistant, wiki writing help, AI project builder) ──
# An OpenRouter key (https://openrouter.ai/keys). Empty: the app works, AI features say "AI isn't set up".
OPENROUTER_API_KEY=
# The server's own AI spending limits, in US dollars per calendar month (UTC); the API and the runner both read them.
# Each workspace's limit when its plan sets none (billing off, or a plan without AI credits). Empty: no limit.
AI_DEFAULT_MONTHLY_CAP_USD=10
# Everything all workspaces spend together in a month; past it AI pauses everywhere. Empty: no limit.
AI_GLOBAL_MONTHLY_CAP_USD=
# true stops every AI call at once (runs stop at their next model call), e.g. if spending runs away.
AI_DISABLED=false
# How many agent runs at once.
RUNNER_CONCURRENCY=4
# How long an AI teammate's action waits for approval, in seconds.
AGENT_APPROVAL_TIMEOUT_SECONDS=1800
# strict: AI teammates only reach https connectors on public hosts. permissive: also http/private (testing).
CONNECTOR_URL_POLICY=strict

# ── Email (invites, password resets) ── empty: emails are only written to the API log.
# e.g. smtp://user:password@smtp.example.com:587
SMTP_URL=
MAIL_FROM=

# ── Uploads: local (a docker volume) or s3 (any S3-compatible storage) ──
STORAGE_DRIVER=local
MAX_UPLOAD_MB=25
S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=

# ── Alerts: a Slack (or Teams/Google Chat-compatible) incoming webhook that hears about failed backups and server
# errors. Empty: they are only in the logs. ──
ALERT_WEBHOOK_URL=

# ── Database backups: a dump every day to this bucket, kept BACKUP_KEEP_DAYS days. Uses the S3_*
# settings above (a separate bucket from uploads is better). Empty bucket: NO BACKUPS (the backup service warns).
# Restore: docs/OPERATIONS.md ──
BACKUP_S3_BUCKET=
BACKUP_S3_PREFIX=taskcrew-db/
BACKUP_KEEP_DAYS=30
BACKUP_INTERVAL_HOURS=24

# ── Push notifications to phones and browsers (both or neither; `npx web-push generate-vapid-keys`) ──
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=

# ── Forms by email (both or neither): the domain of the forms' addresses and the webhook password ──
INBOUND_EMAIL_DOMAIN=
INBOUND_EMAIL_SECRET=

# ── Billing (off: every workspace is unlimited) ──
BILLING_ENABLED=false

# ── Maps (empty: OpenStreetMap tiles; "off" hides maps) ──
TASKCREW_MAP_TILE_URL=
TASKCREW_MAP_ATTRIBUTION=
TASKCREW_MAP_MAX_ZOOM=

These pages are built from the same docs that ship with the code, so they change when the product does. See what changed in the changelog