Self-hosting

Run the tracker and its agent interface on your infrastructure.

Nothing Left is open source under AGPL-3.0. A self-hosted install runs the same build as the hosted service: the web app, one API process that also serves MCP, PostgreSQL, Redis and S3-compatible storage.

No reduced community edition: plans are limit presets, not feature gates.

Same codebase. Your infrastructure. Agent access included.

Two processes you run and three services you provide. MCP is not a separate server: it is mounted on the API, so an install that serves people also serves their agents, through the same permissions.

Clients

  • Peoplein a browser, signed in with a session cookie
  • Agentsin an MCP client, with a personal access token

You run

  • Web appNext.js build

    The interface people use. The browser calls the API directly, so the build needs one setting: NEXT_PUBLIC_API_BASE_URL, matching the API’s API_BASE_URL, inlined at build time.

    Source: apps/web/.env.example

  • APIFastify on Bun, one process/v1 REST/mcp MCP/health

    The only backend process. It must be long-running, not serverless: the job queue holds a Postgres connection and runs scheduled work in-process.

    MCP is part of the same process. Its tools call the REST API over loopback, so an agent passes the same permissions and rules as a person. Configuration is validated at boot, and the process refuses to start on a bad one.

    Source: docs/architecture.md § apps/api · docs/infrastructure.md § Hosting requirements

You provide

  • PostgreSQLStandard wire protocol

    Every record, and the background job queue. Two connection strings: DATABASE_URL (direct) for migrations and jobs, DATABASE_URL_POOLED for application queries.

    Two roles: the owner migrates, and nothingleft_app runs every query. The audit log is append-only for that role, so it must not own tables or be a superuser.

    Source: docs/infrastructure.md § Environment configuration, § Database roles

  • RedisAny Redis 6 or later

    Session cache, rate-limit counters, report caches and idempotency replays. It is not the job queue. Outside localhost and the Compose stack, REDIS_URL must use TLS (rediss://).

    Source: docs/backend-guide.md · docs/tech-stack.md · apps/api/.env.example

  • Object storageAny S3-compatible store, two bucketsprivate attachments, exports, importspublic avatars only

    The private bucket has no public access: the API checks each request and redirects to a short-lived presigned URL. The public bucket holds avatars and nothing else.

    File bytes never pass through the API. Browsers upload and download directly with presigned URLs; the API only authorises.

    Source: docs/file-storage.md § Principles, § Two buckets

People reach the web app and the API; agents reach /mcp on the same API. Only the API talks to PostgreSQL and Redis. Browsers move file bytes straight to and from the buckets with presigned URLs, so the API only authorises. Open a service to see what it does.

What the repository gives you.

docker-compose.yml runs the whole system on one machine: the web app, the API, PostgreSQL 16, Redis 7 and RustFS as the S3-compatible store, with Caddy for HTTPS on a server. You build the images from source, and your settings live in one file, infra/docker/.env.

A long-running API.

Not serverless. The job queue holds a Postgres connection and runs scheduled work inside the API process.

Web and API on one domain.

Sharing a registrable domain is what lets the session cookie stay SameSite=Lax. Set SESSION_COOKIE_DOMAIN to cover both.

A separate application role.

Create nothingleft_app before the first migration. The owner migrates; the application role runs queries and cannot rewrite the audit log.

Checked at boot.

Every variable is validated when the API starts. A bad configuration stops it starting, rather than failing on the first request.

docker-compose.ymlThe whole system, as committed.
services:
  postgres:
    image: postgres:16
    command:
      - postgres
      - -c
      - shared_preload_libraries=pg_stat_statements
      - -c
      - log_min_duration_statement=200
    environment:
      POSTGRES_USER: nothingleft
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nothingleft}
      POSTGRES_DB: nothingleft
      APP_DB_PASSWORD: ${APP_DB_PASSWORD:-nothingleft_app}
    ports: ['127.0.0.1:5432:5432']
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./infra/docker/postgres-init.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro
    healthcheck:
      test: ['CMD', 'pg_isready', '-U', 'nothingleft', '-d', 'nothingleft']
      interval: 2s
      retries: 30

  redis:
    image: redis:7
    ports: ['127.0.0.1:6379:6379']
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 2s
      retries: 30

  # S3-compatible stand-in for R2 (MinIO no longer publishes images). The app profile creates the
  # buckets; from source, run `cd apps/api && bun run storage:init` after putting the S3_* lines in .env.
  storage:
    image: rustfs/rustfs:latest
    environment:
      RUSTFS_ACCESS_KEY: nothingleft
      RUSTFS_SECRET_KEY: ${STORAGE_SECRET_KEY:-nothingleft-secret}
    ports: ['127.0.0.1:9000:9000']
    volumes: ['storage-data:/data']
    healthcheck:
      test: ['CMD', 'curl', '-fs', 'http://localhost:9000/health']
      interval: 2s
      retries: 30

  migrate:
    <<: *api-image
    profiles: [app]
    working_dir: /app/packages/db
    command: [bun, run, db:migrate]
    environment: *backing
    depends_on:
      postgres: { condition: service_healthy }

  storage-init:
    <<: *api-image
    profiles: [app]
    command: [bun, run, storage:init]
    env_file: [{ path: infra/docker/.env, required: false }]
    environment:
      <<: [*backing, *storage]
    depends_on:
      storage: { condition: service_healthy }

  api:
    <<: *api-image
    profiles: [app]
    restart: unless-stopped
    env_file: [{ path: infra/docker/.env, required: false }]
    environment:
      <<: [*backing, *storage]
    ports: ['127.0.0.1:4000:4000']
    depends_on:
      migrate: { condition: service_completed_successfully }
      storage-init: { condition: service_completed_successfully }
      redis: { condition: service_healthy }

  web:
    image: nothingleft-web
    build:
      context: .
      dockerfile: apps/web/Dockerfile
      args:
        NEXT_PUBLIC_API_BASE_URL: ${API_BASE_URL:-http://localhost:4000}
        NEXT_PUBLIC_SUPPORT_EMAIL: ${SUPPORT_EMAIL:-}
    profiles: [app]
    restart: unless-stopped
    ports: ['127.0.0.1:3000:3000']
    depends_on: [api]

  # Catches every email at http://localhost:8025 until a real mail server is configured.
  mailpit:
    image: axllent/mailpit:latest
    profiles: [app]
    ports: ['127.0.0.1:8025:8025']

  # TLS for a server: certificates for APP_HOST, API_HOST and STORAGE_HOST, issued automatically.
  caddy:
    image: caddy:2
    profiles: [proxy]
    restart: unless-stopped
    environment:
      APP_HOST: ${APP_HOST:-}
      API_HOST: ${API_HOST:-}
      STORAGE_HOST: ${STORAGE_HOST:-}
    ports: ['80:80', '443:443']
    volumes:
      - ./infra/docker/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
    depends_on: [web, api, storage]

volumes:
  postgres-data:
  storage-data:
  caddy-data:
apps/api/.envAn excerpt, placeholder values.
NODE_ENV=production
PORT=4000

# Direct for migrations and jobs; pooled for queries
DATABASE_URL=postgres://OWNER:PASSWORD@DB_HOST:5432/nothingleft
DATABASE_URL_POOLED=postgres://nothingleft_app:PASSWORD@DB_HOST:5432/nothingleft
REDIS_URL=rediss://default:PASSWORD@REDIS_HOST:6379

# Web and API on one registrable domain
APP_BASE_URL=https://work.example.com
API_BASE_URL=https://api.example.com
SESSION_COOKIE_DOMAIN=.example.com
TRUST_PROXY=1

# Two buckets on any S3-compatible store
S3_ENDPOINT=https://s3.example.com
S3_REGION=auto
S3_ACCESS_KEY_ID=ACCESS_KEY_ID
S3_SECRET_ACCESS_KEY=SECRET_ACCESS_KEY
S3_PRIVATE_BUCKET=nl-private
S3_PUBLIC_BUCKET=nl-public
S3_PUBLIC_BUCKET_BASE_URL=https://static.example.com

# Required in production
ENCRYPTION_MASTER_KEY=BASE64_OF_32_BYTES
EMAIL_LINK_SECRET=AT_LEAST_32_CHARACTERS
# Mail provider: resend (default), zoho or smtp, with that provider’s credentials
EMAIL_PROVIDER=resend
RESEND_API_KEY=re_xxxxxxxx
EMAIL_FROM="Nothing Left <noreply@example.com>"

# Free-plan limits (defaults shown)
FREE_SEAT_LIMIT=20
FREE_STORAGE_BYTES=5368709120

# Product analytics and error capture, each off
POSTHOG_ANALYTICS_ENABLED=false
POSTHOG_ERRORS_ENABLED=false
apps/web/.envOne setting.
# Inlined at build time; match API_BASE_URL
NEXT_PUBLIC_API_BASE_URL=https://api.example.com

Placeholders in capitals and example.com addresses stand in for yours. Never commit real values.

The free plan’s limits are two environment settings.

A plan is a limit preset and nothing more: no billing code, no feature gating. On your own install, these two variables replace the free plan’s numbers without touching the code. Each takes a positive whole number.

  • FREE_SEAT_LIMITDefault: 20 members

    Where the seat notice starts. Members beyond it are still allowed: the invite dialog and a banner for admins say so, and nothing is blocked.

  • FREE_STORAGE_BYTESDefault: 5368709120 bytes, 5 GB

    The storage cap for attachments. Warnings at 80% and 95%; at 100% new uploads are refused until there is room. Avatars never count.

Per-file and rate limits stay as they are: 100 MB a file, 50 attachments an issue, and API rate limits returned in response headers.

Bringing your own bucket is not self-hosting.

On the hosted service, Org Admins and the Owner can connect their own bucket under Organisation → Storage. Files then live in your account, on your bill. The app, the database and avatars stay with us. Self-hosting moves all of it.

Connecting your own bucket

A storage option

Your own bucket, on the hosted service

Runs the app
Nothing Left
Issues, comments, accounts
The hosted database
Attachments, exports, imports
Your Cloudflare R2 or AWS S3 bucket
Avatars
Nothing Left’s public bucket
Storage limit
Lifted as soon as the bucket is verified

A deployment

Self-hosting the whole app

Runs the app
You
Issues, comments, accounts
Your PostgreSQL
Attachments, exports, imports
Your private bucket
Avatars
Your public bucket
Storage limit
Whatever you set in FREE_STORAGE_BYTES

Check it the way the hosted service is checked.

  1. Migrations. They apply before the API starts: Docker Compose runs its migrate job on every start, and the AWS guide runs a one-off migrate task.
  2. Liveness. /health answers as soon as the process is up. It checks no dependency and is never rate limited.
  3. Readiness. /health/deep checks the database, Redis and the private bucket. Any failure answers 503 with degraded; a bucket not yet configured reads not_configured and does not fail it.

The readiness check is anonymous, so it is bounded: ten requests a minute per address, one probe at a time, and one answer shared for five seconds. Poll it every ten seconds or slower.

Health checksResponses use the API’s one envelope.
$ curl https://api.example.com/health
{ "data": { "status": "ok" }, "meta": { "request_id": "…" } }

$ curl https://api.example.com/health/deep
{
  "data": {
    "status": "ok",
    "checks": { "db": "ok", "redis": "ok", "storage": "ok" }
  },
  "meta": { "request_id": "…" }
}

Upgrades

The hosted service applies migrations before a new API version takes traffic, and CI checks that every schema change ships with its migration and that they apply to an empty database. On your own install, an upgrade is a pull of the new source and a rebuild, with the database migrated before the new API starts. Take a backup first.

Backups

Durable state lives in PostgreSQL and the two buckets; Redis holds caches and counters. Keep the encryption key with your backups: without it, two-factor and storage secrets cannot be read.

Managed or self-hosted: who does what.

The software is the same either way. What changes is who keeps it running. Self-host when you need the data on your own infrastructure and have someone to operate it; otherwise the hosted beta is the shorter path.

Responsibilities on the hosted service and on a self-hosted install
Hosted serviceSelf-hosted
Running the servicesWe run the web app, the API with /mcp, the database, Redis and the buckets.You run the web app, the API, PostgreSQL, Redis and an S3-compatible store. Docker Compose starts all of them on one machine.
UpgradesWe deploy them. Migrations run before a new API version takes traffic.You pull the new source, rebuild and restart. Migrations run before the new API starts; each guide has the steps.
BackupsOurs, for the hosted database and platform storage. A bucket you connect is yours.Yours: PostgreSQL and both buckets, plus the encryption key. Each guide says what to keep and how.
EmailAccount, security, invitation and storage email is sent for you.You pick a mail provider (Resend, Zoho CPaaS or any SMTP server), supply its credentials and set EMAIL_FROM. In production the API does not start without them; a trial on your laptop catches mail in Mailpit.
File storage5 GB per organisation on the free plan, or connect your own R2 or S3 bucket.Your own store and two buckets. FREE_STORAGE_BYTES sets the cap.
Google and GitHub sign-inReady to use.Register your own OAuth apps. Until you do, those buttons answer that the provider is not switched on.
Agent endpointhttps://api.nothingleft.dev/mcp/mcp on your own API address.

Questions before you run it yourself.

Licence, data location, where to install it and who can sign up.

What does the AGPL-3.0 licence ask of us?

You may run, study, change and share the code. If you modify it and let people use your version over a network, the licence asks you to offer those users its source. The licence text is the authority, not this summary. Read the licence

Where does our data live?

Self-hosted: in your PostgreSQL database and your two buckets, wherever you run them. Product analytics and error capture are separate switches, and with both off nothing is sent.

On the hosted service, issues, comments and accounts are in our database. Attachments can live in your own R2 bucket (with an EU or FedRAMP jurisdiction) or S3 bucket in your region; avatars stay with us.

Is the self-hosted build different from the hosted one?

No. It is the same codebase and the same build. Plans are limit presets, not feature gates, so there is no reduced community edition and nothing to unlock.

Does docker-compose.yml run the whole app?

Yes. Its app profile builds the web app and the API from source and starts them with PostgreSQL 16, Redis 7, RustFS for S3-compatible storage and Mailpit to catch email on a trial. Migrations run before the API starts. On a server, the proxy profile adds Caddy for HTTPS.

Settings live in one file, infra/docker/.env. The guide covers a trial on your laptop, a server, upgrades and backups. Self-hosting with Docker Compose

Can we run it on AWS?

Yes. The web app and the API run as containers on ECS Fargate, with RDS for PostgreSQL, ElastiCache for Valkey, two S3 buckets and SES for mail. A load balancer is the only way in; everything else sits in private subnets. Deploying on AWS

Can we turn off sign-up?

No. Anyone who can open the web app can create an account and their own organisation, and there is no setting that turns this off. To keep an install to your team, put it where only your team can reach it: a private network, a VPN or a firewall rule.

Can our agents connect to a self-hosted install?

Yes. MCP is served by the API at /mcp, so an HTTP client points at your own address with a personal access token. The local stdio shim reads the address from NOTHINGLEFT_MCP_URL. MCP setup guide

Is connecting our own bucket the same as self-hosting?

No. Your own R2 or S3 bucket is a storage option on the hosted service: attachments move into your account and the storage limit lifts, while the app and its database stay with us. Self-hosting means running all of it yourself. Storage manual

Read the code before you run it.

The source, the licence and the install guides are public. Try it on your own machine first, or join the hosted beta, which runs the same build.