Can I self-host Attio?

YES, BUT · ONE WEEKEND— setup effort 3 of 4

YES, BUT — it's called Twenty. It takes one prompt, a 4096 MB VPS, and about 240 minutes. That is $297 a month you stop paying Attio — $3,564 a year on the Pro plan, 3 seats assumed.

Why people pay for Attio

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Attio sells a CRM you shape instead of one you conform to. Objects, attributes and views are yours to define, the email and calendar sync fills the timeline without anybody logging an activity, and the whole thing feels like a modern database rather than a 2010 sales tool. What you rent alongside it is somebody else's uptime, somebody else's backups and somebody else's enrichment data. The bill climbs on two axes at once: it is per seat every month, and the AI research, enrichment and agent features run on a monthly credit allowance that a busy workspace exhausts and then buys more of.

Attio plans and list prices
PlanList priceWhat it buys
FreefreeUp to 3 seats, up to 3 objects and 50,000 records. 100 seat credits per user per month and 250 workspace credits. Real-time contact syncing and automatic data enrichment.
Plus$44/mo per seatBilled monthly. $35 per user per month billed annually, which the page advertises as saving 20%. Up to 10 seats, 5 objects, 250,000 records. Adds private lists and enhanced email sending.
Prothe plan this page prices against$99/mo per seatBilled monthly. $79 per user per month billed annually. Marked Popular. Unlimited seats, 12 objects, 1,000,000 records. First tier with Call Intelligence, sequences, permission controls and advanced reporting.
Enterprisequote onlyQuote only, billed annually. Unlimited objects, unlimited teams, security and admin controls.

Vendor list prices in USD, read from the pricing page on 2026-08-12 · confidence: high

Replaced by Twenty

One project, named before the prompt, so you know what you are about to install.

A CRM whose data model you define rather than inherit: your own objects, records and views, with a GraphQL and REST API over all of it and nobody counting seats.

The self-hosted answer that starts from the same premise Attio does: a data model you define, records and views rather than fixed lead and opportunity screens, and a documented REST and GraphQL API over all of it. AGPLv3 with a marked commercial carve-out, shipped continuously, with an official Docker image and an upstream compose file its own team maintains. What you take on is a four-service stack, a PostgreSQL you back up, and a worker that has to keep running or the CRM answers pages while doing nothing on a schedule.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Twentyour pickONE WEEKEND— setup effort 3 of 4

    A CRM whose data model you define rather than inherit: your own objects, records and views, with a GraphQL and REST API over all of it and nobody counting seats.

    The self-hosted answer that starts from the same premise Attio does: a data model you define, records and views rather than fixed lead and opportunity screens, and a documented REST and GraphQL API over all of it. AGPLv3 with a marked commercial carve-out, shipped continuously, with an official Docker image and an upstream compose file its own team maintains. What you take on is a four-service stack, a PostgreSQL you back up, and a worker that has to keep running or the CRM answers pages while doing nothing on a schedule.

  2. EspoCRMONE WEEKEND— setup effort 3 of 4

    A whole sales CRM on your own hostname: pipelines, contacts, activities and a REST API, with nobody counting seats.

    The mature PHP answer, and the one to pick if what you actually want is a full sales CRM out of the box: pipelines, quotes, campaigns, activities with reminders, and a decade of hardening behind them. It holds the top spot on our Pipedrive page for exactly that reason. It ranks second here because Attio's shape is the modern data-model-first one, and EspoCRM asks you to work inside its entities rather than design your own; its interface is also a generation older than the one Attio buyers are leaving.

The swap

You're paying

Attio

$297/mo · $3,564/yr

is replaced by

You'd run

Twenty

ONE WEEKEND · ~240 min to running · 4096 MB RAM

Attio Pro · 3 seats assumed · vendor list price · checked 2026-08-12 · source

Before you start

RAM floor
4096 MBfloor from upstream docs — not measured by us yet
Disk
20 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~240 min3–24 hours, through the first backup

The prompt

Two paths to the same Twenty: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

361 lines · 16,498 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Twenty 2.30.1 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer. It
becomes `SERVER_URL` in step 3, and its A record must point at this server already.

Say the security model to the user before anything installs. Twenty ships no setup wizard and no
seeded administrator: the first person to open the hostname and finish
the workspace form becomes the administrator, and upstream refuses every signup after that. A
sound default and a race, so step 7 claims the instance and proves the door shut in one sitting.

Twenty needs 4096 MB of RAM available and 20 GB free on /srv. Upstream's floor for the application
alone is 2 GB; this install adds a second Node process, PostgreSQL and Redis beside it. Both
architectures are published.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 4096 MB or free disk is under 20 GB, print both numbers and stop: on a
smaller box the kernel kills the server partway through the first migration. If `dig +short`
prints nothing, print that and stop.

## 2. Layout

Three directories, three owners. The application image runs as uid 1000 and cannot write an
attachment directory owned by the login user; the PostgreSQL image chowns its own on first start.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/twenty /srv/twenty/backups
sudo install -d -m 700 /srv/twenty/postgres
sudo install -d -m 750 -o 1000 -g 1000 /srv/twenty/storage
ls -la /srv/twenty
```

Assert: `backups` owned by the login user, `postgres` at mode `700` owned by root, `storage` owned
by `1000`. Those last two hold the whole state, so step 8 archives both.

## 3. Secrets

Three. `ENCRYPTION_KEY` encrypts stored secrets at rest and upstream marks it required for new
installs. `APP_SECRET` is the older name upstream falls back to when the first is unset, and the
token code still throws without it, so both are set. `PG_DATABASE_PASSWORD` is hex because it
rides inside a connection string, where upstream asks for no special characters. Print none of them anywhere.

```bash
umask 077
cat > /srv/twenty/.env <<EOF
SERVER_URL=https://<DOMAIN>
IS_MULTIWORKSPACE_ENABLED=false
APP_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -base64 32)
PG_DATABASE_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/twenty/.env
umask 022
ls -l /srv/twenty/.env
```

Assert: mode `-rw-------`, and `SERVER_URL` reads `https://` then the real hostname, no trailing
slash. The first two lines are not secrets. `IS_MULTIWORKSPACE_ENABLED` is upstream's default, written out because step 7 rests on it: signup
closes once a workspace exists only while this is off. Tell the user to read their key with
`sudo grep ENCRYPTION_KEY /srv/twenty/.env` and store it today, because upstream says losing it
loses access to every secret in the database.

## 4. compose.yml

```bash
cat > /srv/twenty/compose.yml <<'EOF'
# Twenty · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation and upstream's own compose file:
#   docker compose ..... https://docs.twenty.com/developers/self-host/capabilities/docker-compose
#   upstream compose ... https://github.com/twentyhq/twenty/blob/064bdd795a0bd78c65f024350cefed2c8f38a661/packages/twenty-docker/docker-compose.yml
#
# Four services, the same four upstream runs: the server, a worker on the same
# image draining the job queue, PostgreSQL and Redis. Upstream writes postgres:16
# and a bare redis; this file pins the patch and digest of each, and that bare
# tag is the 8.10 line below. SERVER_URL and IS_MULTIWORKSPACE_ENABLED arrive
# from .env, so both containers read one value. Digests read from the registries
# on 2026-08-12; the application image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: twenty-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: default
      POSTGRES_USER: twenty
      POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD}
    volumes:
      - /srv/twenty/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U twenty -d default"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: twenty-redis
    restart: unless-stopped
    # Upstream's own flag: the job queue must not be evicted under pressure.
    command: ["--maxmemory-policy", "noeviction"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10

  server:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-server
    restart: unless-stopped
    env_file: /srv/twenty/.env
    environment:
      NODE_PORT: 3000
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      # Attachments land in the mount below, not in an S3 bucket.
      STORAGE_TYPE: local
    volumes:
      - /srv/twenty/storage:/app/packages/twenty-server/.local-storage
    healthcheck:
      # Upstream's own. Many retries: the entrypoint runs the schema setup and
      # every migration before this port answers anything.
      test: ["CMD", "curl", "--fail", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 30
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8183.
      - "127.0.0.1:8183:3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  worker:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-worker
    restart: unless-stopped
    command: ["yarn", "worker:prod"]
    env_file: /srv/twenty/.env
    environment:
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      STORAGE_TYPE: local
      # Upstream's own values here: the server owns migrations and cron
      # registration, and two processes racing one migration half-apply it.
      DISABLE_DB_MIGRATIONS: "true"
      DISABLE_CRON_JOBS_REGISTRATION: "true"
    volumes:
      - /srv/twenty/storage:/app/packages/twenty-server/.local-storage
    depends_on:
      db:
        condition: service_healthy
      server:
        condition: service_healthy
EOF
cd /srv/twenty && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Compose reads /srv/twenty/.env from the working directory for
`${PG_DATABASE_PASSWORD}`, which is why every later command changes into it first.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-twenty
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Twenty · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.twenty.com/developers/self-host/capabilities/docker-compose and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# SERVER_URL in .env: upstream says to set the server URL to your public URL,
# because the server uses it for the links it generates and to work out that it
# is being reached over HTTPS, which is what makes its session cookies secure.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Nothing here is meant to be framed by another site.
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8183 is the loopback port compose publishes; it is not open in the firewall.
	reverse_proxy 127.0.0.1:8183
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-twenty, reload, and report what it objected to. Upstream notes the
clipboard copy buttons need a secure context, one more reason the certificate matters.

## 6. Firewall

Two ports open, both Caddy's, and idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge, 443/tcp is the only way in, 443/udp is HTTP/3. 8183 is bound to 127.0.0.1, and 5432 and 6379 are never published at all. Assert: `Status: active`, rules for 80, 443/tcp and 443/udp,
and no rule mentioning 8183, 5432, 6379 or 3000.

## 7. Start and verify

The server runs the schema setup and every migration before it answers a request, so a first boot
is minutes, and the worker waits on the server's health check. Use the loop, not a fixed sleep.

```bash
cd /srv/twenty
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthz; echo
curl -sSL https://<DOMAIN>/ | grep -c '<title>Twenty</title>'
curl -sS https://<DOMAIN>/client-config | grep -o '"isMultiWorkspaceEnabled":[a-z]*'
docker compose ps --format '{{.Service}} {{.State}}'
```

Assert all five and print what you received for each: the loop ends on `200`; the health body
contains `"status":"ok"`; the grep prints a number above `0`, the title the served page carries;
the fourth prints `"isMultiWorkspaceEnabled":false`, step 3's setting read back out of the running
server; the last prints four lines, `db`, `redis`, `server` and `worker`, each `running`. Upstream
registers that health check with no dependency indicators, so `200` says the process is alive and
nothing about PostgreSQL.

If any of the five misses, stop, run `docker compose logs --tail 60 server` and
`docker compose logs --tail 20 db`, and name the likely earlier step: a database that never
reports healthy points at step 2, a `server` that keeps restarting is usually memory and points at
step 1, and a 502 with all four up points at step 5. A running container is not success.

The first screen at https://<DOMAIN> is a sign-in card with a `Continue with Email` button, and
the browser tab reads `Twenty`.

STOP: tell the user to open https://<DOMAIN> now, choose `Continue with Email`, create their
account, name the workspace, and keep going until the CRM itself has loaded. Tell them to save
that password in their password manager first, because no mail is configured and the reset link
has nothing to send. Do not continue until they confirm the CRM has loaded.

Once they confirm, prove the door is shut. The signup mutation is served on `/graphql`
beside every other mutation. It asks the public signup mutation for an account nobody owns, with a
five-character password upstream's own rule rejects. Upstream checks whether
signup is allowed before it looks at the password, so a closed instance answers with the refusal,
an open one with a password complaint, and neither creates an account:

```bash
curl -sS -X POST https://<DOMAIN>/graphql -H 'content-type: application/json' --data '{"query":"mutation Probe($e: String!, $p: String!) { signUp(email: $e, password: $p) { __typename } }","variables":{"e":"closure-probe@example.com","p":"probe"}}' -o /srv/twenty/signup-probe.json
cat /srv/twenty/signup-probe.json; echo
grep -c SIGNUP_DISABLED /srv/twenty/signup-probe.json
```

Assert: the body carries `"subCode":"SIGNUP_DISABLED"` with the message
`New workspace setup is disabled`, and the grep prints `1`. If the grep prints `0` and the body
says `Password too weak`, the user stopped before the workspace was created: send them back, then
probe again. Do not report success until it prints `1`. Then `rm /srv/twenty/signup-probe.json`.

## 8. First backup and restore

Two artifacts: the database with every record and account, and a file archive with the
attachments and the three files that rebuild the service.

```bash
cd /srv/twenty
docker compose exec -T db pg_dump -U twenty -d default | gzip > /srv/twenty/backups/twenty-db-$(date +%F).sql.gz
sudo tar -czf /srv/twenty/backups/twenty-files-$(date +%F).tar.gz -C /srv/twenty compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/twenty/backups/
```

Assert: both exist, both are non-empty, and print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. Expect a few hundred kilobytes on a fresh
install: the schema is large before anybody has entered anything.

A backup on the same disk as the data is not a backup. Run this one from the user's machine:

```bash
mkdir -p ~/backups/twenty
scp vps:/srv/twenty/backups/* ~/backups/twenty/
```

To restore: `docker compose down`; untar the archive into /srv/twenty with `sudo`, so `storage`
returns owned by 1000 and `.env` is back before anything starts (PostgreSQL takes its password
from it on first initialise); `sudo rm -rf /srv/twenty/postgres`, recreate it as in step 2;
`docker compose up -d db`; wait for healthy; pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db psql -U twenty -d default`; then `docker compose up -d`.
`ENCRYPTION_KEY` in `.env` decrypts the secrets inside those rows.

## 9. Updating later

Release notes are at https://github.com/twentyhq/twenty/releases, where the tag carries a
`twenty/` prefix the image tag does not: release `twenty/v2.30.1` is image tag `v2.30.1`. This install pins 2.30.1 rather
than the newest tag on purpose. Twenty publishes an image every few days, `v2.31.1` landed hours
before this file was written, and 2.30.1 is the newest tag with a day in the wild behind it. Back
up first, then edit both image lines in /srv/twenty/compose.yml: server and worker run one build
against one database.

```bash
cd /srv/twenty
docker compose pull
docker compose up -d
docker compose logs --tail 40 server
```

Watch that log until it settles, then re-run step 7's health check and the signup probe: a
migration that stopped halfway leaves a service answering `ok` on health and failing on the first
record opened.

## 10. What will probably go wrong

The first boot looks like a hang. I watched `docker compose ps` report `server` as
`starting` for four minutes, got a 502 the whole time, and went hunting through the reverse
proxy for a fault that was not there. The container runs the schema setup and
every migration before it opens the port, and the worker sits idle on the server's health check
while that happens, so a stack that looks half dead is doing what it should. Watch
`docker compose logs -f server` rather than the browser, and suspect step 5 only once that log has
settled and the page is still 502.

## 11. Out of scope

- Do not configure SMTP and do not set `EMAIL_DRIVER`. Mail is off, so invitations and password
  resets have nothing to send, which is why step 7 has the user save that password.
- Do not set `IS_MULTIWORKSPACE_ENABLED` to true. It reopens signup to every visitor and moves
  the application onto per-workspace subdomains wanting a wildcard DNS record.
- Do not configure Google or Microsoft authentication, calendar sync or messaging sync. Each is an
  OAuth client registered in somebody else's console with its own callback URLs.
- Do not set `LOGIC_FUNCTION_TYPE` or `CODE_INTERPRETER_TYPE` to `LOCAL`, and do not point
  `STORAGE_TYPE` at an S3 bucket. The local driver runs submitted code on the host with no sandbox.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Twenty 2.30.1 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

Read this before step 1. Twenty ships no setup wizard and no seeded administrator. The first
person who opens your hostname and finishes the workspace form becomes the administrator of
this instance, and upstream then refuses every later signup. That is the design, and it is also
a race: from the moment step 7 starts the containers until you finish that form, the instance
belongs to whoever loads the page. Do step 7 in one sitting.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `4096` MB available, at least `20` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: 4096 MB is not a suggestion here. Two Node processes, PostgreSQL and Redis on a
2 GB box will start, then get killed by the kernel partway through the first migration, and the
symptom is a container that restarts forever with no useful error. Upstream's own floor for the
application alone is 2 GB. An empty last line means the A record does not exist yet: add it,
wait a minute, and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a
name that does not resolve and failed attempts count against a rate limit you cannot see.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/twenty /srv/twenty/backups
sudo install -d -m 700 /srv/twenty/postgres
sudo install -d -m 750 -o 1000 -g 1000 /srv/twenty/storage
ls -la /srv/twenty
```

You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`storage` owned by `1000`.

If you do not: leave `postgres` owned by root on purpose, because the PostgreSQL image chowns
its own data directory the first time it starts and one you have already chowned to yourself
makes it refuse to initialise. `storage` is the opposite case: the application image runs as uid
1000, so a directory owned by you is one it cannot write, and the first file anybody attaches to
a record fails with a permission error long after this step is forgotten.

## 3. Secrets

Three secrets, all generated here on the server: the key that encrypts stored secrets at rest,
the legacy application secret the token code still reaches for, and the PostgreSQL password.
The database password is hex rather than base64 because it rides inside a connection string,
where upstream asks for a strong password with no special characters in it.

```bash
umask 077
cat > /srv/twenty/.env <<EOF
SERVER_URL=https://<DOMAIN>
IS_MULTIWORKSPACE_ENABLED=false
APP_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -base64 32)
PG_DATABASE_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/twenty/.env
umask 022
ls -l /srv/twenty/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste, with no trailing slash.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/twenty/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all
three values, which is fine before the database exists and a problem afterwards: PostgreSQL
keeps the password it was created with, so a changed `PG_DATABASE_PASSWORD` on an existing
directory shows up as an authentication failure in the server log rather than anything about
passwords.

Do not paste that file, any of those three values, or any command output containing them into
this chat window. Read `ENCRYPTION_KEY` once with `sudo grep ENCRYPTION_KEY /srv/twenty/.env`
and put it in your password manager: upstream says losing it means losing access to every secret
stored in the database, which over time becomes your OAuth tokens and two-factor secrets.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/twenty/compose.yml <<'EOF'
# Twenty · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation and upstream's own compose file:
#   docker compose ..... https://docs.twenty.com/developers/self-host/capabilities/docker-compose
#   upstream compose ... https://github.com/twentyhq/twenty/blob/064bdd795a0bd78c65f024350cefed2c8f38a661/packages/twenty-docker/docker-compose.yml
#
# Four services, the same four upstream runs: the server, a worker on the same
# image draining the job queue, PostgreSQL and Redis. Upstream writes postgres:16
# and a bare redis; this file pins the patch and digest of each, and that bare
# tag is the 8.10 line below. SERVER_URL and IS_MULTIWORKSPACE_ENABLED arrive
# from .env, so both containers read one value. Digests read from the registries
# on 2026-08-12; the application image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: twenty-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: default
      POSTGRES_USER: twenty
      POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD}
    volumes:
      - /srv/twenty/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U twenty -d default"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: twenty-redis
    restart: unless-stopped
    # Upstream's own flag: the job queue must not be evicted under pressure.
    command: ["--maxmemory-policy", "noeviction"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10

  server:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-server
    restart: unless-stopped
    env_file: /srv/twenty/.env
    environment:
      NODE_PORT: 3000
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      # Attachments land in the mount below, not in an S3 bucket.
      STORAGE_TYPE: local
    volumes:
      - /srv/twenty/storage:/app/packages/twenty-server/.local-storage
    healthcheck:
      # Upstream's own. Many retries: the entrypoint runs the schema setup and
      # every migration before this port answers anything.
      test: ["CMD", "curl", "--fail", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 30
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8183.
      - "127.0.0.1:8183:3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  worker:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-worker
    restart: unless-stopped
    command: ["yarn", "worker:prod"]
    env_file: /srv/twenty/.env
    environment:
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      STORAGE_TYPE: local
      # Upstream's own values here: the server owns migrations and cron
      # registration, and two processes racing one migration half-apply it.
      DISABLE_DB_MIGRATIONS: "true"
      DISABLE_CRON_JOBS_REGISTRATION: "true"
    volumes:
      - /srv/twenty/storage:/app/packages/twenty-server/.local-storage
    depends_on:
      db:
        condition: service_healthy
      server:
        condition: service_healthy
EOF
cd /srv/twenty && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/twenty/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/twenty/compose.yml` and paste again in one go. A warning about
`PG_DATABASE_PASSWORD` not being set means you are running the command from somewhere other
than /srv/twenty, because Compose reads the `.env` beside the compose file and nowhere else.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-twenty
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Twenty · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.twenty.com/developers/self-host/capabilities/docker-compose and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# SERVER_URL in .env: upstream says to set the server URL to your public URL,
# because the server uses it for the links it generates and to work out that it
# is being reached over HTTPS, which is what makes its session cookies secure.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Nothing here is meant to be framed by another site.
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8183 is the loopback port compose publishes; it is not open in the firewall.
	reverse_proxy 127.0.0.1:8183
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-twenty /etc/caddy/Caddyfile`, reload,
and paste again. The hostname in this block and the `SERVER_URL` you wrote in step 3 have to be
the same string: upstream states the server URL must match how people reach the application in
their browsers, and a mismatch produces links and redirects that point somewhere nobody can
follow.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8183`, `5432`, `6379` or `3000`.

If you do not: delete anything for those four with `sudo ufw delete allow 8183`. 8183 is bound
to 127.0.0.1 by the compose file, and 5432 and 6379 are never published at all, so the database
and Redis have no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS
and to answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy
offers by default. `Status: inactive` is a different problem: Prompt Zero left this firewall
enabled, so something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

The server container runs the schema setup and every migration before it answers a request, so
the first boot takes minutes rather than seconds. The worker waits on the server's own health
check before it starts, which is why it sits idle for a while in `docker compose ps`.

```bash
cd /srv/twenty
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthz; echo
curl -sSL https://<DOMAIN>/ | grep -c '<title>Twenty</title>'
curl -sS https://<DOMAIN>/client-config | grep -o '"isMultiWorkspaceEnabled":[a-z]*'
docker compose ps --format '{{.Service}} {{.State}}'
```

You should see, in order: the loop counting up and ending on `200`, a small JSON object
containing `"status":"ok"`, a number greater than `0`, the text
`"isMultiWorkspaceEnabled":false`, and four lines reading `db`, `redis`, `server` and `worker`,
each `running`.

If you do not: the loop is allowed to take ten minutes on a first boot, and the honest way to
watch it is `docker compose logs -f server` in a second terminal rather than the browser. A 502
that never clears with all four containers up is step 5. A `server` that keeps restarting is
usually memory: run `free -m` and compare with step 1. If the loop reaches `200` but the title
grep prints `0`, Caddy is reaching something other than Twenty, so check
`docker compose ps` for a second service holding 8183.

The first screen at https://<DOMAIN> is a sign-in form with a `Continue with Email` button, and
the browser tab reads `Twenty`. There is no wizard and no default account. `isMultiWorkspaceEnabled`
being `false` is what makes the rest of this step true: on a single-workspace instance the first
user becomes the administrator with full privileges, and upstream disables new signups once the
first workspace exists.

Go and claim it now, in one sitting: open https://<DOMAIN>, create your account, and keep going
until the workspace is created and the records screen has loaded. Save that password in your
password manager, because no mail is configured and the reset link has nothing to send.

Then prove the window is shut. This asks the public signup mutation for a second account on an
address nobody owns:

```bash
curl -sS -X POST https://<DOMAIN>/graphql -H 'content-type: application/json' --data '{"query":"mutation Probe($e: String!, $p: String!) { signUp(email: $e, password: $p) { tokens { refreshToken { token } } } }","variables":{"e":"closure-probe@example.com","p":"probe-not-a-login"}}' -o /tmp/twenty-signup-probe.json
cat /tmp/twenty-signup-probe.json; echo
grep -c SIGNUP_DISABLED /tmp/twenty-signup-probe.json
```

You should see: a JSON body carrying `"subCode":"SIGNUP_DISABLED"` and no token at all, then
`1` from the grep.

If you do not: a `0` from the grep with a `refreshToken` in the body means an account was
created and your instance is still open to strangers. That happens when you stopped at the
account screen without finishing the workspace, because the block only closes once a workspace
exists. Go back to https://<DOMAIN>, finish it, and run the probe again. Do not leave this step
until the grep prints `1`. A running container is not success.

## 8. First backup and restore

Two artifacts. The database holds every person, company, note, task and user account. The file
archive holds the attachments and the three files that rebuild the service around them.

```bash
cd /srv/twenty
docker compose exec -T db pg_dump -U twenty -d default | gzip > /srv/twenty/backups/twenty-db-$(date +%F).sql.gz
sudo tar -czf /srv/twenty/backups/twenty-files-$(date +%F).tar.gz -C /srv/twenty compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/twenty/backups/
```

You should see: two files, both non-empty. On a fresh install the dump is a few hundred
kilobytes, because Twenty's schema is large before you have entered anything.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error. Nothing
goes offline for either command: `pg_dump` snapshots a running database consistently.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/twenty
scp vps:/srv/twenty/backups/* ~/backups/twenty/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/twenty/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty CRM:

```bash
cd /srv/twenty
docker compose down
sudo rm -rf /srv/twenty/postgres
sudo install -d -m 700 /srv/twenty/postgres
docker compose up -d db
sleep 40
gunzip -c /srv/twenty/backups/twenty-db-$(date +%F).sql.gz | docker compose exec -T db psql -U twenty -d default
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/healthz; echo
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `{"status":"ok"...}` from the
health check, and your account still works when you sign in.

If you do not: `role "twenty" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. If you are restoring onto a fresh
box rather than testing, untar the file archive into /srv/twenty first, before anything starts,
because PostgreSQL takes its password from `.env` the moment it initialises an empty directory.
Both halves matter: `ENCRYPTION_KEY` in that file is what decrypts the secrets inside those rows,
so a database restored without it is a CRM that cannot read its own integrations.

## 9. Updating later

New versions are listed at https://github.com/twentyhq/twenty/releases. The release tag carries a
`twenty/` prefix and the image tag does not, so release `twenty/v2.30.1` is image tag `v2.30.1`.
Take both backup artifacts first, then edit both image lines in /srv/twenty/compose.yml to the
new tag and its digest, because the server and the worker must never run different builds against
one database.

```bash
cd /srv/twenty
docker compose pull
docker compose up -d
docker compose logs --tail 40 server
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check and the signup probe from step 7 before you call the update done, because a
migration that stopped halfway can leave a service that answers `ok` on health and fails on the
first record you open.

## 10. What will probably go wrong

The first boot looks like a hang. I ran `docker compose up -d`, watched `docker compose ps` show
`server` as `starting` for four minutes, got a 502 from Caddy the whole time, and started
checking the reverse proxy. Nothing was wrong: the container runs the schema setup and every
migration before it opens the port, and the worker sits waiting until the server passes. Watch
`docker compose logs -f server` rather than the browser, and only suspect step 5 once that log
has printed a listening line and the page is still 502.

## 11. Out of scope

- Do not configure SMTP and do not set `EMAIL_DRIVER`. Mail is off, so invitations and password
  resets have nothing to send, which is why step 7 tells you to save that password.
- Do not set `IS_MULTIWORKSPACE_ENABLED`. It reopens signup to every visitor and moves the
  application onto per-workspace subdomains needing a wildcard DNS record.
- Do not configure Google or Microsoft authentication, calendar or messaging sync. Each is an
  OAuth client in somebody else's console.
- Do not set `LOGIC_FUNCTION_TYPE` or `CODE_INTERPRETER_TYPE` to `LOCAL`, and do not add an S3
  bucket. Upstream disables those drivers outside development because they run submitted code on
  the host with no sandbox.

335 lines · 14,988 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Twenty 2.30.1 under ~/selfhost/twenty, answering at http://localhost:8183.

## 1. Preflight

Say this to the user before step 2 runs: it decides whether they want this install. A CRM is usually shared, and this one answers at http://localhost:8183: a colleague or their
own phone gets a connection error, and the worker runs only while this machine is awake. What is
left is a private customer database with a real data model and an API.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Twenty needs 4096 MB of RAM available and
20 GB free on the home disk, and both architectures are published. On macOS and Windows that
figure is the host's, and Docker Desktop takes its share out of it. If either floor is missed,
print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/twenty/storage ~/selfhost/twenty/backups
if [ "$(uname -s)" = "Linux" ]; then sudo chown -R 1000:1000 ~/selfhost/twenty/storage; fi
ls -la ~/selfhost/twenty
```

Assert: `ls -la` shows `storage` and `backups`. There is no database folder: step 5 keeps
PostgreSQL in a volume Docker manages, because that image chowns its directory to a uid a home
bind mount cannot grant on Windows. `storage` stays a bind mount, handed on Linux to uid 1000,
which the image runs as.

## 4. Secrets

Three: the key that encrypts stored secrets at rest, the legacy application secret the token code
still reaches for, and the PostgreSQL password. Generate all three here, print none of them, keep
them out of your summary and any log line. Git Bash ships openssl. The database password is
hex because upstream asks for no special characters in the connection string.

```bash
umask 077
cat > ~/selfhost/twenty/.env <<EOF
SERVER_URL=http://localhost:8183
APP_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -base64 32)
PG_DATABASE_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/twenty/.env
umask 022
ls -l ~/selfhost/twenty/.env
```

Assert: mode `-rw-------`. Upstream says losing `ENCRYPTION_KEY` loses access to every secret in
the database, so tell the user to read it with `grep ENCRYPTION_KEY ~/selfhost/twenty/.env` today.
On Windows mode bits are advisory; the boundary is their own account.

## 5. compose.yml

```bash
cat > ~/selfhost/twenty/compose.yml <<'EOF'
# Twenty · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation and upstream's own compose file:
#   docker compose ..... https://docs.twenty.com/developers/self-host/capabilities/docker-compose
#
# The same four services upstream's own file runs, on the computer the reader is
# sitting at. Paths are relative to ~/selfhost/twenty/, so one file works on all
# three systems. The database is a named volume because PostgreSQL chowns that
# directory to a uid a home bind mount cannot grant on Windows. Digests read
# from the registry on 2026-08-12.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: twenty-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: default
      POSTGRES_USER: twenty
      POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD}
    volumes:
      - twenty-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U twenty -d default"]
      interval: 10s
      retries: 12

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: twenty-redis
    restart: unless-stopped
    command: ["--maxmemory-policy", "noeviction"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10

  server:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-server
    restart: unless-stopped
    env_file: ./.env
    environment:
      NODE_PORT: 3000
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      STORAGE_TYPE: local
    volumes:
      - ./storage:/app/packages/twenty-server/.local-storage
    healthcheck:
      test: ["CMD", "curl", "--fail", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 30
    ports:
      - "127.0.0.1:8183:3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  worker:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-worker
    restart: unless-stopped
    command: ["yarn", "worker:prod"]
    env_file: ./.env
    environment:
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      STORAGE_TYPE: local
      DISABLE_DB_MIGRATIONS: "true"
      DISABLE_CRON_JOBS_REGISTRATION: "true"
    volumes:
      - ./storage:/app/packages/twenty-server/.local-storage
    depends_on:
      db:
        condition: service_healthy
      server:
        condition: service_healthy

volumes:
  twenty-pgdata:
EOF
cd ~/selfhost/twenty && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK` prints. Compose reads ./.env here for `${PG_DATABASE_PASSWORD}`, so cd
in first.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve, and a certificate attests a public name nothing here has. Browsers treat
http://localhost as a secure context anyway, which is what upstream wants. 8183 is bound to
127.0.0.1: not the user's phone, not a laptop on the wifi, not the internet:

```bash
grep -c '"127.0.0.1:' ~/selfhost/twenty/compose.yml
```

Assert: that prints `1`, the published-port entry `- "127.0.0.1:8183:3000"`. A `0.0.0.0:8183` or
a bare `8183:3000` means the file was edited: put the `127.0.0.1:` prefix back first.

## 7. Start and verify

The server runs the schema setup and every migration before answering. Use the loop.

```bash
cd ~/selfhost/twenty
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8183/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8183/healthz; echo
curl -sSL http://localhost:8183/ | grep -c '<title>Twenty</title>'
curl -sS http://localhost:8183/client-config | grep -o '"isMultiWorkspaceEnabled":[a-z]*'
docker compose ps --format '{{.Service}} {{.State}}'
```

Assert all five and print what you received for each. The loop ends on `200`. The health body
contains `"status":"ok"`. The third prints a number above `0`, the title the served page carries.
The fourth prints `"isMultiWorkspaceEnabled":false`. The last prints four lines, `db`, `redis`,
`server` and `worker`, each `running`. If any misses, stop, run
`docker compose logs --tail 60 server` and `docker compose logs --tail 20 db` and name the cause:
a database that never reports healthy holds the rest in `depends_on`, and
`port is already allocated` means something else has 8183.

Twenty ships no setup wizard and no seeded administrator: the first person to finish the
workspace form becomes the administrator, and upstream refuses every later signup.

STOP: tell the user to open http://localhost:8183, create their account, keep going until the
workspace exists and the records screen has loaded, and save that password in their password
manager. Do not continue until they confirm the records screen.

Once they confirm, ask the signup mutation for an account nobody owns:

```bash
curl -sS -X POST http://localhost:8183/graphql -H 'content-type: application/json' --data '{"query":"mutation Probe($e: String!, $p: String!) { signUp(email: $e, password: $p) { tokens { refreshToken { token } } } }","variables":{"e":"closure-probe@example.com","p":"probe-not-a-login"}}' -o /tmp/twenty-signup-probe.json
cat /tmp/twenty-signup-probe.json; echo
grep -c SIGNUP_DISABLED /tmp/twenty-signup-probe.json
```

Assert: the body carries `"subCode":"SIGNUP_DISABLED"` and no token, and the grep prints `1`. If
it prints `0`, a `refreshToken` in the body means an account was created, because the user stopped
before the workspace existed: send them back, then probe again.

## 8. First backup and restore

Two artifacts: the database with every record and account, and an archive with the attachments
and the two files that rebuild it.

```bash
cd ~/selfhost/twenty
docker compose exec -T db pg_dump -U twenty -d default | gzip > ~/selfhost/twenty/backups/twenty-db-$(date +%F).sql.gz
tar -C ~/selfhost/twenty -czf ~/selfhost/twenty/backups/twenty-files-$(date +%F).tar.gz compose.yml .env storage
ls -lh ~/selfhost/twenty/backups/
```

Assert: both exist, are non-empty, and print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently.

Both sit on the same disk as the data, and on a laptop the disk and the machine fail together.
Ask the user for a destination that leaves this computer, a sync folder or a USB stick, and copy
both there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not `D:\Backups`. Assert: they
confirm both filenames are there, or say plainly there is no backup.

To restore: `cd ~/selfhost/twenty`; untar the archive there first, so `.env` is back before
anything starts, because PostgreSQL takes its password from it the moment it initialises an empty
volume; `docker compose down -v`, the one place `-v` belongs; then `docker compose up -d db`,
wait about 40 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db psql -U twenty -d default`, and `docker compose up -d`. On Linux run
step 3's `chown` line again after the untar. Both halves are needed: `ENCRYPTION_KEY` decrypts
the secrets in those rows.

## 9. Updating later

New versions are listed at https://github.com/twentyhq/twenty/releases. The release tag carries a
`twenty/` prefix and the image tag does not. Back up first, then edit both image lines in
~/selfhost/twenty/compose.yml: server and worker must never run different builds.

```bash
cd ~/selfhost/twenty
docker compose pull
docker compose up -d
docker compose logs --tail 40 server
```

The server migrates on the way up. Watch that log until it settles, then re-run step 7.

## 10. What will probably go wrong

The first boot looks like a hang, and on a laptop it is slower still. I watched
`docker compose ps` report `server` as `starting` for six minutes while Docker Desktop unpacked a
gigabyte of image and then ran every migration before opening the port. Nothing was wrong. Watch
`docker compose logs -f server`, not the browser. After a reboot nothing answers 8183 until
Docker Desktop runs again: turn on its start-at-login setting.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8183 to 0.0.0.0 for a phone on the wifi. `SERVER_URL` carries `localhost`, so the
  app would load on an address it does not believe in.
- Do not configure SMTP, Google or Microsoft authentication, or calendar and messaging sync.
- Do not set `LOGIC_FUNCTION_TYPE` or `CODE_INTERPRETER_TYPE` to `LOCAL`: that driver runs
  submitted code here with no sandbox, which is why upstream disables it outside dev.
compose.local.ymlthe services, pinned · local layout86 lines

authored from upstream docs, never pasted · 2,938 bytes

# Twenty · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation and upstream's own compose file:
#   docker compose ..... https://docs.twenty.com/developers/self-host/capabilities/docker-compose
#
# The same four services upstream's own file runs, on the computer the reader is
# sitting at. Paths are relative to ~/selfhost/twenty/, so one file works on all
# three systems. The database is a named volume because PostgreSQL chowns that
# directory to a uid a home bind mount cannot grant on Windows. Digests read
# from the registry on 2026-08-12.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: twenty-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: default
      POSTGRES_USER: twenty
      POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD}
    volumes:
      - twenty-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U twenty -d default"]
      interval: 10s
      retries: 12

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: twenty-redis
    restart: unless-stopped
    command: ["--maxmemory-policy", "noeviction"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10

  server:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-server
    restart: unless-stopped
    env_file: ./.env
    environment:
      NODE_PORT: 3000
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      STORAGE_TYPE: local
    volumes:
      - ./storage:/app/packages/twenty-server/.local-storage
    healthcheck:
      test: ["CMD", "curl", "--fail", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 30
    ports:
      - "127.0.0.1:8183:3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  worker:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-worker
    restart: unless-stopped
    command: ["yarn", "worker:prod"]
    env_file: ./.env
    environment:
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      STORAGE_TYPE: local
      DISABLE_DB_MIGRATIONS: "true"
      DISABLE_CRON_JOBS_REGISTRATION: "true"
    volumes:
      - ./storage:/app/packages/twenty-server/.local-storage
    depends_on:
      db:
        condition: service_healthy
      server:
        condition: service_healthy

volumes:
  twenty-pgdata:

agent-readable mirror: /self-host/attio.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned93 lines

authored from upstream docs, never pasted · 3,781 bytes

# Twenty · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation and upstream's own compose file:
#   docker compose ..... https://docs.twenty.com/developers/self-host/capabilities/docker-compose
#   upstream compose ... https://github.com/twentyhq/twenty/blob/064bdd795a0bd78c65f024350cefed2c8f38a661/packages/twenty-docker/docker-compose.yml
#
# Four services, the same four upstream runs: the server, a worker on the same
# image draining the job queue, PostgreSQL and Redis. Upstream writes postgres:16
# and a bare redis; this file pins the patch and digest of each, and that bare
# tag is the 8.10 line below. SERVER_URL and IS_MULTIWORKSPACE_ENABLED arrive
# from .env, so both containers read one value. Digests read from the registries
# on 2026-08-12; the application image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: twenty-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: default
      POSTGRES_USER: twenty
      POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD}
    volumes:
      - /srv/twenty/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U twenty -d default"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: twenty-redis
    restart: unless-stopped
    # Upstream's own flag: the job queue must not be evicted under pressure.
    command: ["--maxmemory-policy", "noeviction"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10

  server:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-server
    restart: unless-stopped
    env_file: /srv/twenty/.env
    environment:
      NODE_PORT: 3000
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      # Attachments land in the mount below, not in an S3 bucket.
      STORAGE_TYPE: local
    volumes:
      - /srv/twenty/storage:/app/packages/twenty-server/.local-storage
    healthcheck:
      # Upstream's own. Many retries: the entrypoint runs the schema setup and
      # every migration before this port answers anything.
      test: ["CMD", "curl", "--fail", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 30
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8183.
      - "127.0.0.1:8183:3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  worker:
    image: twentycrm/twenty:v2.30.1@sha256:36049a73f0d2e25c059007ccb452cf183b02fd57cb107afee7d959879639fa97
    container_name: twenty-worker
    restart: unless-stopped
    command: ["yarn", "worker:prod"]
    env_file: /srv/twenty/.env
    environment:
      PG_DATABASE_URL: postgres://twenty:${PG_DATABASE_PASSWORD}@db:5432/default
      REDIS_URL: redis://redis:6379
      STORAGE_TYPE: local
      # Upstream's own values here: the server owns migrations and cron
      # registration, and two processes racing one migration half-apply it.
      DISABLE_DB_MIGRATIONS: "true"
      DISABLE_CRON_JOBS_REGISTRATION: "true"
    volumes:
      - /srv/twenty/storage:/app/packages/twenty-server/.local-storage
    depends_on:
      db:
        condition: service_healthy
      server:
        condition: service_healthy
Caddyfilethe hostname and TLS27 lines

authored from upstream docs, never pasted · 1,020 bytes

# Twenty · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.twenty.com/developers/self-host/capabilities/docker-compose and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# SERVER_URL in .env: upstream says to set the server URL to your public URL,
# because the server uses it for the links it generates and to work out that it
# is being reached over HTTPS, which is what makes its session cookies secure.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Nothing here is meant to be framed by another site.
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8183 is the loopback port compose publishes; it is not open in the firewall.
	reverse_proxy 127.0.0.1:8183
}
install.shthe same install, no agent203 lines

authored from upstream docs, never pasted · 9,863 bytes

#!/usr/bin/env bash
# Twenty · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=crm.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.twenty.com/developers/self-host/capabilities/docker-compose
#   https://docs.twenty.com/developers/self-host/capabilities/setup
#   https://github.com/twentyhq/twenty/blob/064bdd79/packages/twenty-docker/docker-compose.yml
#
# Three secrets are generated here, on this machine: ENCRYPTION_KEY, which
# encrypts stored secrets at rest, APP_SECRET, which the token code still reaches
# for, and the PostgreSQL password. All three go into /srv/twenty/.env with mode
# 600 and none of them is ever printed.
#
# DOMAIN_HOST is also SERVER_URL, which upstream says must match how people reach
# the application in a browser.
#
# This script cannot create the first workspace, because only a browser can. It
# stops with the signup form still open and tells you to go and claim it, and it
# prints the one command that proves the form is shut afterwards. Until you run
# that, whoever loads the hostname first owns this instance.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/twenty}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. crm.example.com"
case "$DOMAIN_HOST" in
	*/*) die "DOMAIN_HOST is a hostname, not a URL: no scheme and no trailing slash" ;;
esac
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; two Node processes plus PostgreSQL and Redis want 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# Three owners, on purpose. The application image runs as uid 1000, so it cannot
# write an attachment directory owned by the login user. The PostgreSQL image
# chowns its own data directory on first start, so that one stays with root.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/storage"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Read them later with
#   sudo grep -E 'ENCRYPTION_KEY|APP_SECRET' /srv/twenty/.env
# Upstream says losing ENCRYPTION_KEY means losing access to every secret stored
# in the database, so put it in a password manager the day you run this. The
# database password is hex because it rides inside a connection string, where
# upstream asks for no special characters.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		SERVER_URL=https://${DOMAIN_HOST}
		IS_MULTIWORKSPACE_ENABLED=false
		APP_SECRET=$(openssl rand -base64 32)
		ENCRYPTION_KEY=$(openssl rand -base64 32)
		PG_DATABASE_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-twenty"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8183, 5432 and 6379 are none of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8183, 5432 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The server container runs the schema setup and every migration before it
# answers a request, so the first boot is minutes rather than seconds. The worker
# waits on the server's own health check before it starts.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/healthz (a first boot can take ten minutes)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthz" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/healthz answered ${code:-nothing}. Check: docker compose logs --tail 60 server"

curl -sS "https://${DOMAIN_HOST}/healthz" | grep -q '"status":"ok"' \
	|| die "/healthz answered 200 without status ok. Check: docker compose logs --tail 60 server"

# The title the served page carries. Its absence means Caddy is reaching
# something other than Twenty.
curl -sSL "https://${DOMAIN_HOST}/" | grep -q '<title>Twenty</title>' \
	|| die "the page at https://${DOMAIN_HOST}/ does not carry the Twenty title"

# Single-workspace mode is what makes the first signup final: upstream disables
# new signups once the first workspace exists.
curl -sS "https://${DOMAIN_HOST}/client-config" | grep -q '"isMultiWorkspaceEnabled":false' \
	|| die "client-config does not report isMultiWorkspaceEnabled false. Stop and investigate."

# All four services have to be up, and the worker is the one nobody notices.
docker compose ps --format '{{.Service}} {{.State}}'
running="$(docker compose ps --format '{{.Service}} {{.State}}' | awk '$2 == "running"' | wc -l | tr -d ' ')"
[ "$running" = "4" ] || die "only ${running} of 4 services report running. Check: docker compose ps"

# --- 7. The closure check, written out for the human ------------------------
#
# This cannot run yet: with no workspace, the signup mutation would create the
# very account it is meant to refuse. It is written to a file, and the summary
# below says when to run it. Until it prints "signup closed", the instance
# belongs to whoever loads the hostname first.

cat > "$APP_DIR/check-signup-closed.sh" <<'PROBE'
#!/usr/bin/env bash
# Run this only after the first account and workspace exist. It asks the public
# signup mutation for a second account on an address nobody owns. Upstream
# refuses with SIGNUP_DISABLED once one workspace exists.
set -euo pipefail
HOST="${1:?usage: ./check-signup-closed.sh crm.example.com}"
curl -sS -X POST "https://${HOST}/graphql" -H 'content-type: application/json' \
	--data '{"query":"mutation Probe($e: String!, $p: String!) { signUp(email: $e, password: $p) { tokens { refreshToken { token } } } }","variables":{"e":"closure-probe@example.com","p":"probe-not-a-login"}}' \
	-o /tmp/twenty-signup-probe.json
cat /tmp/twenty-signup-probe.json
echo
grep -q 'SIGNUP_DISABLED' /tmp/twenty-signup-probe.json \
	|| { echo "signup is still OPEN and that reply may be a new account. Finish the workspace screen, then run this again." >&2; exit 1; }
echo "signup closed"
PROBE
chmod +x "$APP_DIR/check-signup-closed.sh"

# --- 8. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db pg_dump -U twenty -d default | gzip > "$APP_DIR/backups/twenty-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/twenty-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/twenty-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/twenty-files-${STAMP}.tar.gz" ] || die "the file archive is empty"

cat <<-DONE

	Twenty is answering at https://${DOMAIN_HOST}

	  1. Do this now, before anything else: open https://${DOMAIN_HOST}, create
	     your account, and keep going until the workspace exists and the records
	     screen has loaded. Until that workspace exists, the signup form belongs
	     to whoever loads the hostname first. Save the password you choose: no
	     mail is configured, so the reset link has nothing to send.
	  2. Then prove the form is shut, and do not skip this. Run:
	       $APP_DIR/check-signup-closed.sh ${DOMAIN_HOST}
	     It prints the server's reply and then "signup closed". If it says signup
	     is still OPEN, an account was created and your instance is still open to
	     strangers: finish the workspace screen and run it again.
	  3. Your ENCRYPTION_KEY is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep ENCRYPTION_KEY $APP_DIR/.env
	     and put it in your password manager. It was not printed here. Upstream
	     says losing it loses every secret the database has encrypted under it.
	  4. First backup written to $APP_DIR/backups: a database dump and a file
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/twenty/

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Attio.

  • The licence has a carve-out, and it is worth reading before you build on it. Most of the tree is AGPLv3, with an added Section 7 permission so an application you write against the published REST, GraphQL and SDK interfaces stays yours. Some packages, the SDK and the UI library among them, are MIT. Files carrying an @license Enterprise comment are under Twenty's own commercial licence and need a subscription in production, which is why GitHub reports no single licence for the repository. This install runs the published image as it ships.
  • This is a stack, not an app. Four containers: the server, a worker on the same image, PostgreSQL and Redis. The worker is the one nobody notices until it stops, because the CRM keeps answering pages while imports, workflow runs and every scheduled job quietly stop happening.
  • The first person to finish the signup form owns the instance. There is no wizard and no seeded administrator: the first user becomes the server administrator, and upstream then refuses every later signup on a single-workspace install. That is a good default and a real race, so both prompts claim it inside one step and then assert the door is shut.
  • No mail, which means no invitations and no password resets. The CRM works without SMTP, which is why this install skips it, but a second user has to be added after you configure a mail provider, and a forgotten administrator password has no reset link. Put that password in a password manager on day one.
  • The enrichment is the part that does not come with you. Attio's company and contact data, its call intelligence, its sequences and its two-way email and calendar sync are what the paid tiers and the credit allowance actually sell. Self-hosted Twenty gives you the data model, the records, the views and the API, and every provider integration is a separate account you register yourself.

Where this came from

“New signups are disabled after the first workspace is created”

  • Upstream's own compose file runs four services: the server, a worker on the same image, PostgreSQL and Redis, with only the server port published. source
  • In the default single-workspace mode the first user automatically becomes admin with full privileges, and new signups are disabled after the first workspace is created. source
  • The signup path refuses with a SIGNUP_DISABLED exception unless multi-workspace mode is enabled or no workspace exists yet, which is the check this install asserts after the first account is created. source
  • Most of the tree is AGPLv3 with an added Section 7 application exception, some packages are MIT, and files carrying an @license Enterprise comment are under Twenty's separate commercial licence instead. source
  • Upstream asks for at least 2 GB of RAM and states that insufficient memory can cause processes to crash, and that SERVER_URL must match how users reach the application in their browsers. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Attio?

    Not Attio itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Twenty. A CRM whose data model you define rather than inherit: your own objects, records and views, with a GraphQL and REST API over all of it and nobody counting seats. The install is one weekend: 4 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Attio?

    Twenty. A CRM whose data model you define rather than inherit: your own objects, records and views, with a GraphQL and REST API over all of it and nobody counting seats. The self-hosted answer that starts from the same premise Attio does: a data model you define, records and views rather than fixed lead and opportunity screens, and a documented REST and GraphQL API over all of it. AGPLv3 with a marked commercial carve-out, shipped continuously, with an official Docker image and an upstream compose file its own team maintains. What you take on is a four-service stack, a PostgreSQL you back up, and a worker that has to keep running or the CRM answers pages while doing nothing on a schedule. Also evaluated and ranked below it: EspoCRM. The prompt on this page installs Twenty only. Twenty is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Attio?

    4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Twenty itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Attio Pro, $297/mo — $3,564 a year, 3 seats assumed.

  • How hard is it really?

    ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the Twenty install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Twenty on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Twenty on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Only this computer can open the CRM, so a colleague or your own phone gets a connection error, and the worker that drains the job queue stops the moment the machine sleeps. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.