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/healthThe 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 buckets
private attachments, exports, importspublic avatars onlyThe 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
/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.
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: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
# 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 membersWhere 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 GBThe 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.
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.
- Migrations. They apply before the API starts: Docker Compose runs its
migratejob on every start, and the AWS guide runs a one-off migrate task. - Liveness.
/healthanswers as soon as the process is up. It checks no dependency and is never rate limited. - Readiness.
/health/deepchecks the database, Redis and the private bucket. Any failure answers503withdegraded; a bucket not yet configured readsnot_configuredand 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.
$ 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.
| Hosted service | Self-hosted | |
|---|---|---|
| Running the services | We 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. |
| Upgrades | We 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. |
| Backups | Ours, 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. |
| Account, 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 storage | 5 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-in | Ready to use. | Register your own OAuth apps. Until you do, those buttons answer that the provider is not switched on. |
| Agent endpoint | https://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.