DocsSelf-hosting

Self-hosting with Docker Compose

Updated 9 min read
On this page

Run the whole of Nothing Left on one machine: the web app, the API with its MCP endpoint, the database, the cache and file storage. The same steps start a trial on your laptop and, with a few more settings, a server your team uses every day. To run it on AWS instead, see Deploying on AWS.

What runs

ServiceWhat it isPort
webThe web app, built from apps/web3000
apiThe REST API, with the MCP endpoint at /mcp, built from apps/api4000
postgresPostgreSQL 16 - every issue, comment and setting5432
redisRedis 7 - rate limits and short-lived caches6379
storageRustFS, an S3-compatible store - attachments in one bucket, avatars in another9000
mailpitCatches outgoing email so you can read it, until you set up real mail8025
caddyHTTPS for a server, with certificates issued automatically443

Two one-off jobs run before the API starts, on every start: migrate brings the database schema up to date, and storage-init creates both buckets and lets the browser upload to them.

Every port is bound to 127.0.0.1, so nothing is reachable from another machine until Caddy serves it.

What you need

  • Docker with Compose 2.24 or later (docker compose version).
  • About 4 GB of memory free while the images build. Running, the stack uses about 1 GB.
  • git and openssl.

Try it on your machine

1. Get the code

git clone https://github.com/nothingleft-dev/nothingleft.git
cd nothingleft

2. Create the settings file and its secrets

cp infra/docker/.env.example infra/docker/.env
sed -i.bak \
  -e "s|^ENCRYPTION_MASTER_KEY=$|ENCRYPTION_MASTER_KEY=$(openssl rand -base64 32)|" \
  -e "s|^EMAIL_LINK_SECRET=$|EMAIL_LINK_SECRET=$(openssl rand -hex 32)|" \
  -e "s|^POSTGRES_PASSWORD=$|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" \
  -e "s|^APP_DB_PASSWORD=$|APP_DB_PASSWORD=$(openssl rand -hex 24)|" \
  -e "s|^STORAGE_SECRET_KEY=$|STORAGE_SECRET_KEY=$(openssl rand -hex 24)|" \
  infra/docker/.env && rm infra/docker/.env.bak

This fills the five secrets in infra/docker/.env. Keep the file: the database passwords are fixed when the database is first created, and ENCRYPTION_MASTER_KEY seals two-factor and storage secrets that nothing can read without it.

3. Start it

docker compose --env-file infra/docker/.env --profile app up -d --build

The first start builds both images and takes a few minutes. Later starts take seconds.

4. Sign up

Open http://localhost:3000 and create an account. The verification email arrives in Mailpit at http://localhost:8025; open its link and carry on as in Getting started. The first person to sign up is not special: each account creates or joins its own organisation.

To stop everything, run the same command with down in place of up -d --build. Your data stays in Docker volumes until you add -v, which deletes it.

Run it on a server

A server needs three host names, HTTPS and a real mail server. Everything else is the same.

1. Point three names at the server

Create an A (or AAAA) record for each, all under the same parent domain:

NameServes
work.example.comThe web app
api.example.comThe API, the MCP endpoint and attachment downloads
storage.example.comFile storage: uploads go straight here from the browser

The web app and the API must share a parent domain. The session cookie is set for .example.com so that both can read it; on two unrelated domains, sign-in cannot work.

Open ports 80 and 443 on the server’s firewall, and no others. Caddy needs port 80 to obtain its certificates.

2. Change the addresses in infra/docker/.env

APP_BASE_URL=https://work.example.com
API_BASE_URL=https://api.example.com
S3_BROWSER_ENDPOINT=https://storage.example.com
S3_PUBLIC_BUCKET_BASE_URL=https://storage.example.com/nl-public
SESSION_COOKIE_DOMAIN=.example.com
APP_HOST=work.example.com
API_HOST=api.example.com
STORAGE_HOST=storage.example.com
TRUST_PROXY=1

TRUST_PROXY=1 tells the API that one proxy, Caddy, sits in front of it, so it records each visitor’s real address instead of Caddy’s.

3. Set up mail

Replace the Mailpit lines with your mail server’s. Any SMTP server works:

EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=...
SMTP_PASSWORD=...
EMAIL_FROM="Nothing Left <noreply@example.com>"
SUPPORT_EMAIL=help@example.com

EMAIL_PROVIDER=resend with RESEND_API_KEY, or zoho with ZOHO_EMAIL_API_KEY, sends through those services’ APIs instead. SUPPORT_EMAIL is where replies to any email go, and the address the app shows where it says “email us”.

4. Start it with HTTPS

docker compose --env-file infra/docker/.env --profile app --profile proxy up -d --build

The web image is rebuilt because its API address changed: the web app’s address for the API is compiled into it. Open https://work.example.com once Caddy has its certificates, usually within a minute.

5. Connect an agent

Agents reach the MCP endpoint at https://api.example.com/mcp. Follow Connecting an AI agent with that address in place of the hosted one; for the stdio shim, set NOTHINGLEFT_MCP_URL to it.

Settings

infra/docker/.env holds everything that differs between installs. The compose file supplies the rest: the database, Redis and storage addresses inside Docker. The settings you are most likely to change:

SettingWhat it does
APP_BASE_URL, API_BASE_URLWhere people reach the web app and the API. Links in emails use them
S3_BROWSER_ENDPOINTWhere browsers reach file storage to upload and download
S3_PUBLIC_BUCKET_BASE_URLWhere browsers load avatars from
SESSION_COOKIE_DOMAINThe parent domain the web app and the API share, with a leading dot
TRUST_PROXYHow many proxies sit in front of the API: false for none, 1 behind Caddy
EMAIL_PROVIDER, SMTP_*, EMAIL_FROMHow mail is sent, and from whom
GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID and secretSign-in with Google or GitHub. Register APP_BASE_URL/auth/google/callback (or github) with the provider
FREE_SEAT_LIMIT, FREE_STORAGE_BYTESThe member count at which the seat notice starts, and each organisation’s storage limit (Limits and billing)
ASSET_BASE_URLWhere the logo in emails loads from. Unset, it loads from the hosted service’s public assets
GITHUB_APP_*Your own GitHub App, so organisations connect GitHub in one click (below)
GIT_ALLOW_PRIVATE_HOSTStrue lets organisations connect a GitLab or GitHub Enterprise on your private network

Product analytics and error reports are off unless you set POSTHOG_KEY and turn them on. The environment reference lists every variable the API reads.

After changing infra/docker/.env, run the start command again. Compose restarts what changed; add --build when an address in APP_BASE_URL, API_BASE_URL or SUPPORT_EMAIL changed, since the web image carries them.

Connect GitHub and GitLab

Organisations link repositories so branches, commits and pull requests show on their issues (Development).

GitLab needs nothing from you: an Org Admin pastes a group or project access token, and the server registers its webhooks. A self-managed GitLab on a private network also needs GIT_ALLOW_PRIVATE_HOSTS=true.

GitHub connects in one click once you register a GitHub App for your install. Without one, GitHub still works in manual mode: an Org Admin adds a webhook to each repository by hand.

To create the app, open GitHub → Settings → Developer settings → GitHub Apps → New GitHub App (under your organisation’s settings to own it there):

FieldValue
Callback URLAPP_BASE_URL/git/connect/github/callback
Request user authorization (OAuth) during installationOn
Setup URLthe same address, with Redirect on update on
Webhook URLAPI_BASE_URL/v1/git/webhooks/github
Webhook secretA random string of 16 characters or more
Repository permissionsMetadata, Contents, Pull requests, Checks and Commit statuses: Read-only
Subscribe to eventsPush, Create, Delete, Pull request, Pull request review, Check run, Status, Repository (installation events arrive without subscribing)
Where can this app be installedAny account, unless only your own organisation will use it

Then generate a private key and set six values in infra/docker/.env:

SettingFrom
GITHUB_APP_IDThe app’s page: App ID
GITHUB_APP_SLUGThe last part of the app’s public address, github.com/apps/<slug>
GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRETThe app’s client ID, and a client secret you generate
GITHUB_APP_WEBHOOK_SECRETThe webhook secret you chose
GITHUB_APP_PRIVATE_KEYThe downloaded .pem, on one line with \n for each line break

Set all six or none: the API refuses to start with some. For GitHub Enterprise Server, create the app there and set GITHUB_APP_BASE_URL to its address. The app is not the one used for signing in with GitHub (GITHUB_CLIENT_ID); keep them separate.

Who can sign up

Anyone who can open the web app’s address can create an account and their own organisation. There is no setting that turns sign-up 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 that allows your offices’ addresses.

Upgrading

git pull
docker compose --env-file infra/docker/.env --profile app --profile proxy up -d --build

Back up first. The migrate job updates the database before the new API starts. If it fails, the command stops before replacing the API, and logs migrate says why.

Backups

Two things hold your data: the database and the file store. Back up both, at the same time.

docker compose --env-file infra/docker/.env exec -T postgres \
  pg_dump -U nothingleft -Fc nothingleft > nothingleft-$(date +%F).dump
docker compose --env-file infra/docker/.env cp storage:/data ./storage-$(date +%F)

Keep infra/docker/.env with them: a database restored without its ENCRYPTION_MASTER_KEY has two-factor and storage secrets nobody can read.

To restore, start from an empty install with the same infra/docker/.env, and bring back the data before the app starts for the first time:

docker compose --env-file infra/docker/.env create storage
docker compose --env-file infra/docker/.env cp ./storage-2026-10-21/. storage:/data
docker compose --env-file infra/docker/.env up -d --wait postgres storage
docker compose --env-file infra/docker/.env exec -T postgres \
  pg_restore -U nothingleft -d nothingleft < nothingleft-2026-10-21.dump
docker compose --env-file infra/docker/.env exec -T postgres psql -U nothingleft -d nothingleft -c \
  "REVOKE ALL ON org_activity FROM PUBLIC, nothingleft_app; GRANT SELECT, INSERT ON org_activity TO nothingleft_app;"

The last line keeps the audit log append-only: a restore otherwise lets the app change past entries. Then start the app as usual.

Day to day

TaskCommand (after docker compose --env-file infra/docker/.env)
Follow the API’s loglogs -f api
See what is runningps
Deactivate an accountexec api bun run account deactivate someone@example.com
Reactivate itexec api bun run account reactivate someone@example.com
Check every dependencycurl -s http://localhost:4000/health/deep on the server

/health answers whenever the API is running; /health/deep also checks the database, Redis and the private bucket. Point an uptime checker at it no more often than every 10 seconds.

When something goes wrong

The API keeps restarting. logs api shows Invalid environment followed by the setting at fault and why. Production refuses to start without ENCRYPTION_MASTER_KEY, EMAIL_LINK_SECRET and the chosen mail provider’s settings.

migrate or the API cannot log in to the database. The two database passwords are set when the database is first created. Changing them in infra/docker/.env afterwards does not change them in PostgreSQL. Put the old values back, or change them in the database with ALTER ROLE.

Sign-in goes straight back to the sign-in page. The browser is not keeping the session cookie. Check that the web app and the API share a parent domain, that SESSION_COOKIE_DOMAIN names it with a leading dot, and that both are served over HTTPS.

Uploads fail. Open the browser’s network panel. A request to the wrong host means S3_BROWSER_ENDPOINT is not the address the browser can reach. A CORS error means the buckets still allow the previous APP_BASE_URL: storage-init updates them on every start, so start the stack again.

No email arrives. Look for email not sent in logs api. On a trial install, the email is in Mailpit at http://localhost:8025.

Still stuck? Ask usSource on GitHub