What the compose file starts
The official docker-compose.yml in packages/twenty-docker defines four services and two named volumes. Only the server publishes a port; the docs' security checklist says to keep PostgreSQL and Redis unpublished.
| Service | Image | Job |
|---|---|---|
| server | twentycrm/twenty | Web app and API on port 3000; runs migrations at startup |
| worker | twentycrm/twenty | Background jobs (yarn worker:prod) |
| db | postgres:16 | All CRM data, in the db-data volume |
| redis | redis | Queues and cache, started with --maxmemory-policy noeviction |
Uploads go to the server-local-data volume, shared by server and worker, unless STORAGE_TYPE=S_3 sends them to a bucket. Its Docker Compose guide sets 2 GB of RAM as the minimum, warning that less can crash processes. It gives no CPU or disk figures; size the disk for your attachments and database growth.
The machine
A dedicated server gives the four containers a whole machine: root, no hypervisor, no other tenant on the disks PostgreSQL writes to. Classes that fit a single-team CRM, in our reading:
- The Xeon E3-1585L v5 cartridge (m710x line) with NVMe storage, when the CRM is the only service on the box.
- Single-socket rack servers when Twenty shares the host with something it talks to, such as a self-hosted n8n, which the docs suggest because cloud automation services cannot reach a private instance.
- Dual-socket servers when other stacks share the machine, such as an Odoo install for accounting.
Both the m710x cartridges and the racked single-, dual- and quad-socket servers carry an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC on the rack machines), so a firewall mistake is fixed from the console, not by a reinstall. Leave out the X-Gene ARM cartridge: it boots only Ubuntu 16.04 and 18.04. Delivery of a ready (racked) server takes about 30 minutes once payment is approved; a build to order takes 4 to 24 hours.
Installing the stack
Run this as root (sudo -i) on Ubuntu 24.04 with Docker installed. It switches ufw's IPv6 handling on before any rule, allows SSH through the OpenSSH profile and enables the firewall, then downloads the compose file from the same release tag as the image, the way the install script keeps the two in step. The sed line is the localhost binding from the docs' security page. .env is created empty at mode 600 before any secret is written into it.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
# Docker Engine 28+ and the Compose plugin, per docs.docker.com (check: docker version)
install -d -m 750 /opt/twenty && cd /opt/twenty
V=v2.50.0 # pin the release you chose from the GitHub releases page
curl -fsSLo docker-compose.yml \
https://raw.githubusercontent.com/twentyhq/twenty/twenty/$V/packages/twenty-docker/docker-compose.yml
sed -i 's/"3000:3000"/"127.0.0.1:3000:3000"/' docker-compose.yml
install -m 600 /dev/null .env
cat >> .env <<EOF
TAG=$V
SERVER_URL=http://localhost:3000
STORAGE_TYPE=local
ENCRYPTION_KEY=$(openssl rand -base64 32)
PG_DATABASE_PASSWORD=$(openssl rand -hex 32)
EOF
docker compose up -d
docker compose ps
ENCRYPTION_KEY encrypts OAuth tokens, application variables and TOTP secrets at rest; lose it and, per the docs, every stored secret is gone. APP_SECRET, which older guides list, is now a legacy fallback used only when ENCRYPTION_KEY is unset. PG_DATABASE_PASSWORD enters the connection URL unencoded, hence hex. SERVER_URL has no compose default and an empty value counts as set, so always fill it in.
The postgres image creates its user from .env on first start only. The troubleshooting page warns that changing PG_DATABASE_PASSWORD afterwards breaks authentication, and its fix deletes the volume: fresh installs only. A port mapping with no address is published on 0.0.0.0 and [::]; Docker-published traffic skips ufw on IPv4, and on IPv6 when Docker's own IPv6 networking is on. Binding to 127.0.0.1 avoids both, and stays local-only on Docker Engine 28+ (moby#45610).
First workspace through a tunnel
Twenty runs in single-workspace mode by default. The setup docs say the first user automatically becomes admin with full privileges and that new sign-ups are disabled once the first workspace exists, so whoever reaches the page first owns the instance. Do it privately, through your own login: ssh -N -L 3000:127.0.0.1:3000 you@crm-server, browse to http://localhost:3000 on your workstation, sign up and create the workspace. Colleagues join by invitation afterwards.
The docs' security page recommends keeping the whole app private and opening only the paths integrations need. The Caddy setup below follows its third option: your office or VPN range gets the app, everyone else gets 403. Order matters: DNS first, then the certificate, then SERVER_URL. Run from the server, the curl check normally prints 403, since the server's own address is not on the allowlist; a 403 over HTTPS still proves the certificate is in place.
# 1. DNS: A (and AAAA) record for crm.example.com -> this server. Wait until it resolves.
ufw allow 80,443/tcp
apt install -y caddy
cat > /etc/caddy/Caddyfile <<'EOF'
crm.example.com {
@team remote_ip 203.0.113.0/24
handle @team {
reverse_proxy 127.0.0.1:3000
}
handle {
respond 403
}
}
EOF
systemctl reload caddy
# 2. Certificate: a status line over HTTPS (403 unless this server is in your range)
curl -sI https://crm.example.com | head -1
# 3. Only now point Twenty at its public name
sed -i 's|^SERVER_URL=.*|SERVER_URL=https://crm.example.com|' /opt/twenty/.env
docker compose -f /opt/twenty/docker-compose.yml down
docker compose -f /opt/twenty/docker-compose.yml up -d
Caddy runs on the host as a normal process, so without the ufw rule it would be blocked on IPv4 and IPv6 alike. Replace 203.0.113.0/24 with your own range. If you use workflow webhooks or email campaigns, add the public paths the security page lists (/webhooks, /s, /emailing) as their own handle blocks; never open /graphql or /rest, which the app itself runs on.
Mail and calendar sync
Most settings live under Settings, Admin Panel, Configuration Variables, stored in the database for both containers; SERVER_URL, the database URL and the encryption keys stay in .env.
- Outgoing mail (invitations, password resets): in the Email section,
EMAIL_DRIVER=smtpwith the host, port, user and password variables (EMAIL_SMTP_*). - Gmail and Google Calendar: a Google Cloud project with the Gmail, Calendar and People APIs enabled, an OAuth client with the redirect URIs
/auth/google/redirectand/auth/google-apis/get-access-tokenon your HTTPS domain, thenMESSAGING_PROVIDER_GMAIL_ENABLED,CALENDAR_PROVIDER_GOOGLE_ENABLEDand theAUTH_GOOGLE_*variables. - Microsoft 365: an Azure app with the Microsoft Graph permissions the docs list (Mail.ReadWrite, Mail.Send and Calendars.ReadWrite are required even for read-only sync). Each user needs a Microsoft 365 licence.
- Sync jobs: the setup page lists seven
yarn command:prod cron:...commands to run in the worker container to register them, for exampledocker compose -f /opt/twenty/docker-compose.yml exec worker yarn command:prod cron:messaging:messages-import.
By default Twenty polls mailboxes and calendars, so Google and Microsoft never call your server and the instance can stay private. They only call in if you set IS_CONNECTED_ACCOUNT_WEBHOOK_SUBSCRIPTION_ENABLED=true, which then needs /webhooks/google or /webhooks/microsoft opened.
Backups and a live restore
The docs back up the database only, and their example names (twenty-postgres, database twenty) differ from what the compose file creates (service db, database default). Uploads and .env matter as much: without ENCRYPTION_KEY the stored OAuth tokens are unreadable. Our script dumps the database, archives the local-storage volume (named twenty_server-local-data after the compose project) and copies .env, checking each archive; pipefail stops a failed dump from leaving a valid-looking file. Pasting it stops nothing.
install -d -m 700 /var/backups/twenty
install -m 700 /dev/null /opt/twenty/backup.sh
cat > /opt/twenty/backup.sh <<'EOF'
set -euo pipefail
umask 077
d=/var/backups/twenty; t=$(date +%F)
c="docker compose -f /opt/twenty/docker-compose.yml"
$c exec -T db pg_dump -U postgres -Fc default > $d/db-$t.dump
$c exec -T db pg_restore -l < $d/db-$t.dump > /dev/null
docker run --rm -v twenty_server-local-data:/data:ro -v $d:/out alpine \
tar czf /out/files-$t.tar.gz -C /data .
tar -tzf $d/files-$t.tar.gz > /dev/null
cp /opt/twenty/.env $d/env-$t
EOF
printf '%s\n' '20 2 * * * root umask 077; bash /opt/twenty/backup.sh > /var/backups/twenty/last.log 2>&1' \
> /etc/cron.d/twenty-backup
A file uploaded between the dump and the archive can be missing from one of them. The dumps and env copies hold secrets: copy them off the machine yourself. The script never deletes anything, so once copies are safely elsewhere, prune old ones by hand or with a retention rule you have tested, before the disk fills. If a root cron job pushes them to another host, create root's key first (ssh-keygen -t ed25519, then ssh-copy-id to the target) or the job stalls at a password prompt.
Restoring over a running instance, as root with c="docker compose -f /opt/twenty/docker-compose.yml", d=/var/backups/twenty and t set to the backup's date:
- Check the backup:
$c exec -T db pg_restore -l < $d/db-$t.dump > /dev/null && tar -tzf $d/files-$t.tar.gz > /dev/null && echo ok. No ok, stop. - Stop the app, as the docs do:
$c stop server worker. - Set the current state aside and check it:
set -o pipefail; $c exec -T db pg_dump --username=postgres -Fc default > $d/pre-restore.dump && $c exec -T db pg_restore -l < $d/pre-restore.dump > /dev/null && echo ok, thendocker run --rm -v twenty_server-local-data:/data:ro -v $d:/out alpine tar czf /out/pre-restore-files.tar.gz -C /data . && tar -tzf $d/pre-restore-files.tar.gz > /dev/null && echo ok. Unless both print ok, stop: nothing has been deleted yet. - Recreate the database and load the dump:
$c exec -T db dropdb -U postgres default,$c exec -T db createdb -U postgres default, then$c exec -T db pg_restore -U postgres -d default < $d/db-$t.dump. - Replace the files:
docker run --rm -v twenty_server-local-data:/data -v $d:/in alpine sh -c "find /data -mindepth 1 -delete && tar xzf /in/files-$t.tar.gz -C /data". tar runs as root in that container, so the original numeric owners come back with the files. - Start and check:
$c up -d, wait until$c pslists the server as healthy, thencurl -fsS http://127.0.0.1:3000/healthzand log in. Delete the two pre-restore files only once you are satisfied; until then they are your way back, restored the same way.
On a new machine, put the backed-up .env in /opt/twenty before the db container ever starts, so PostgreSQL initialises with the same password, then start only db, restore, and start the rest.
Upgrading between versions
The upgrade guide's sequence, written here to work from any directory with c as above: back up, $c down, change TAG in /opt/twenty/.env, $c up -d. The server runs the required migrations on startup with no manual command. We also fetch the new tag's compose file and reapply the 127.0.0.1 sed, keeping file and image matched. Check with $c exec server yarn command:prod upgrade:status (add --failed-only if a step fails; a restart retries it).
- Older than v1.23: upgrade one tagged version at a time (v1.6 to v1.7, then v1.8, and so on) until v1.23. From there a single jump to the latest release is supported.
- v2.5: secrets move into an
enc:v2:envelope, with a slow, resumable backfill on first boot. Set a dedicatedENCRYPTION_KEYbefore this upgrade; switching afterwards needs the key rotation procedure. - v2.34: the minimum database version becomes PostgreSQL 15. The compose file's postgres:16 already qualifies; an external database must be upgraded first.
The guide describes no downgrade. Our reading: going back means the previous TAG and a restore of the pre-upgrade backup. The security page asks for TAG pinned to a version and upgrades done on purpose, the same day for critical security fixes.
The licence and what it asks of you
The root LICENSE file says the project is mostly under AGPL-3.0, with two qualifications. Files headed /* @license Enterprise */ are under the Twenty.com Commercial License instead, which allows production use only with a valid Twenty Enterprise Edition subscription for the right number of hosts and seats; copying and modifying them for development and testing needs none. Some packages are MIT: the application toolkit (twenty CLI, SDKs, create-twenty-app, twenty-shared), twenty-ui and the apps under packages/twenty-apps. An added AGPL section 7 permission lets an application that only uses Twenty's APIs, webhooks and SDKs carry any licence.
Our general reading of the AGPL, not legal advice: running Twenty unmodified for your own team carries no duty. Giving copies to others, modified or not, means providing the source with them. A modified version that users reach over a network means offering those users its source (section 13). The MIT packages only require keeping their copyright notice and licence text. The SSO configuration page lists the Organization plan as a prerequisite, and sso.service.ts (in packages/twenty-server/src/engine/core-modules/sso/services/) opens with the /* @license Enterprise */ header, seen in the source, not stated in the docs.
When this is the wrong setup
- You need several servers behind a load balancer: the setup docs then point uploads to S3 rather than a local volume.
- You want hosted automation tools (Zapier, Make, n8n Cloud) to call the API: the docs note they cannot reach a private instance, and making
/graphqlor/restpublic exposes everything.
If the database outgrows the CRM box, a separate database machine can take it through the PG_DATABASE_* variables, on version 15 or newer.
Questions
Is a self-hosted Twenty free to run?
The AGPL-licensed core carries no licence fee. Files marked with the Enterprise header are under a commercial licence and need a Twenty Enterprise Edition subscription for production use. The machine is the other cost; see the dedicated range for that.
Does self-hosted Twenty do single sign-on?
The self-hosting setup guide configures Google and Microsoft sign-in through the AUTH_GOOGLE_* and AUTH_MICROSOFT_* variables and their redirect URIs. The SSO configuration page (SAML 2.0, Google Workspace, Microsoft Entra ID) lists the Organization plan as a prerequisite for cloud and self-hosted workspaces, and sso.service.ts in the server's core-modules/sso/services/ folder opens with the /* @license Enterprise */ header.
How do I keep strangers from signing up?
In the default single-workspace mode, sign-up closes once the first workspace is created, and further users arrive by invitation. Hence the private first sign-up through a tunnel, done while nothing is public yet. Multi-workspace mode can limit new workspaces to server admins with IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true.