Run Twenty, the open-source CRM, with rootless Podman

Twenty on Debian 13, the open-source CRM for your companies, contacts and deals, in rootless Podman with PostgreSQL and Valkey under a user of its own behind Caddy, with the first account made over an SSH tunnel and telemetry off.

Tested on Twenty 2.46.0 on Debian 13 (trixie) on a Melonslab server Updated October 14, 2026

Recommended server for this guide

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy · 2 vCPU · 8 GB Memory · 60 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€8.99/mo

Deploy now
On this page

What you will set up

Twenty is a CRM: it keeps track of the companies you work with, the people at them, and the deals you hope to win, with notes, tasks and files on each. It looks and works much like a spreadsheet, with views you filter, sort and group, a board for your deals, and a search that covers everything. You can add your own fields and objects, and it has an API, webhooks and workflows for automation.

Twenty is developed in the open on GitHub by Twenty.com PBC, a company registered in Delaware in the United States, which also sells it as a hosted service. It is open source under the AGPL-3.0 licence. A few features, such as single sign-on and audit logs, need a paid licence key and are marked Organization in the settings.

Here Twenty runs as a pod under a user of its own called twenty, from Twenty's official image, with PostgreSQL for its data and Valkey for its job queue, behind Caddy from the Podman guide.

Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:

  • Twenty 2.46.0 started with PostgreSQL 16 and Valkey 9. The first account was created over an SSH tunnel before Twenty could be reached from the internet. Afterwards, a sign-up from outside was refused.
  • A company, a person, a deal, a note and a task were created, and a file was attached to the person. Search found a word in the note's text.
  • A colleague joined with an invitation, and another through the invite link. With the invite link switched off, a third was refused.
  • Two-factor authentication worked, and the list of devices showed each visitor's real address, over IPv4 and IPv6, and ignored a forged X-Forwarded-For header.
  • A webhook reached another app on the same server with the setting in step 10, and Twenty still showed each visitor's real address.
  • The interface was shown in Swedish.
  • Without email set up, invitations and password resets were written to the worker's log instead, as step 9 describes.
  • A mailbox on a test mail server was connected with Connect via IMAP/SMTP, and an email from a customer was imported and shown with that person. A mail server on a private address was refused until it was allowed, as step 9 shows.
  • Twenty's built-in workflow kept a copy of each new person in its run log, and the copy stayed after the person was deleted for good. Step 7 shows how to stop that.
  • With telemetry switched off, the server sent nothing to Twenty when a user signed up. With it on, it contacted twenty-telemetry.com.
  • An update from Twenty 2.45.8 to 2.46.0 went through podman auto-update. Twenty was restored from a backup into empty volumes, and everything came back by itself after a reboot.

The pod used about 1.5 GB of memory once it had settled, nearly all of it the Twenty server and its worker, at about 730 MB each; PostgreSQL and Valkey used about 50 MB together. Right after a start it was about 2.2 GB. The images take about 1.8 GB of disk.

Before you start

You need:

  • a server set up as in the Podman guide, with Caddy running, and at least 4 GB of memory;
  • an A record and an AAAA record for crm.example.com pointing at your server.

The examples use crm.example.com for Twenty and 203.0.113.10 for your server's IPv4 address, which ip -brief address show eth0 shows. Replace them throughout.

1. Create the user

As root:

useradd -m -s /bin/bash twenty
loginctl enable-linger twenty
machinectl shell twenty@

Everything up to step 5 runs as twenty.

2. Create the secrets

openssl rand -hex 24 | tr -d '\n' | podman secret create twenty-db-password -
podman secret inspect --showsecret --format 'postgres://twenty:{{.SecretData}}@127.0.0.1:5432/twenty' twenty-db-password | tr -d '\n' | podman secret create twenty-db-url -
openssl rand -base64 32 | tr -d '\n' | podman secret create twenty-encryption-key -

The first line makes the database password. Twenty reads it as part of a database address, so the second line builds that address with the password in it and stores it as a secret of its own. The third makes the encryption key, which Twenty uses for sessions, two-factor secrets and the passwords of connected accounts. Without it a restored backup is of little use, so step 11 saves a copy. All three are stored by Podman, not in the files below.

3. Define the pod

mkdir -p ~/.config/containers/systemd
cd ~/.config/containers/systemd

Create twenty.pod:

[Pod]
PodName=twenty
# Only Caddy, on this server, can reach Twenty: the port is not open to the internet.
PublishPort=127.0.0.1:8117:3000

[Install]
WantedBy=default.target

Create twenty-db.container:

[Container]
ContainerName=twenty-db
Image=docker.io/library/postgres:16
Pod=twenty.pod
Volume=twenty-db:/var/lib/postgresql/data
Environment=POSTGRES_DB=twenty POSTGRES_USER=twenty
Secret=twenty-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U twenty -d twenty
HealthInterval=10s
Notify=healthy
AutoUpdate=registry

[Service]
Restart=always

Create twenty-redis.container:

[Container]
ContainerName=twenty-redis
Image=docker.io/valkey/valkey:9-alpine
Pod=twenty.pod
Exec=--maxmemory-policy noeviction
AutoUpdate=registry

[Service]
Restart=always

Create twenty-server.container:

[Unit]
Requires=twenty-db.service twenty-redis.service
After=twenty-db.service twenty-redis.service

[Container]
ContainerName=twenty-server
Image=docker.io/twentycrm/twenty:v2
Pod=twenty.pod
Volume=twenty-files:/app/packages/twenty-server/.local-storage
Environment=SERVER_URL=http://localhost:8117 TRUST_PROXY=203.0.113.10
Environment=TELEMETRY_ENABLED=false
Environment=REDIS_URL=redis://127.0.0.1:6379 STORAGE_TYPE=local
Secret=twenty-db-url,type=env,target=PG_DATABASE_URL
Secret=twenty-encryption-key,type=env,target=ENCRYPTION_KEY
HealthCmd=curl -fs http://127.0.0.1:3000/healthz
HealthInterval=10s
Notify=healthy
AutoUpdate=registry

[Service]
Restart=always
TimeoutStartSec=900

And twenty-worker.container:

[Unit]
Requires=twenty-server.service
After=twenty-server.service

[Container]
ContainerName=twenty-worker
Image=docker.io/twentycrm/twenty:v2
Pod=twenty.pod
Exec=yarn worker:prod
Volume=twenty-files:/app/packages/twenty-server/.local-storage
Environment=SERVER_URL=http://localhost:8117
Environment=TELEMETRY_ENABLED=false
Environment=REDIS_URL=redis://127.0.0.1:6379 STORAGE_TYPE=local
Environment=DISABLE_DB_MIGRATIONS=true DISABLE_CRON_JOBS_REGISTRATION=true
Secret=twenty-db-url,type=env,target=PG_DATABASE_URL
Secret=twenty-encryption-key,type=env,target=ENCRYPTION_KEY
AutoUpdate=registry

[Service]
Restart=always

What the settings do:

  • The server serves the web app and the API. The worker runs everything in the background: sending email, imports, workflows and scheduled jobs. They use the same image, and both need the same settings, so a change to one usually goes in the other too. Only the server updates the database.
  • SERVER_URL is the address Twenty puts in links, such as those in invitations. It is http://localhost:8117 for now, so that you can create your account through an SSH tunnel in step 4; step 5 changes it to https://crm.example.com.
  • TRUST_PROXY is the address Twenty trusts to tell it the visitor's real address. Caddy's connections arrive in the pod from the server's own IPv4 address, as the Podman guide explains, so that is the one to name. Without it, the list of devices in each user's profile shows 203.0.113.10 for everyone. If you give the twenty user the containers.conf from step 10, name 10.0.2.0 instead.
  • TELEMETRY_ENABLED=false stops the server from sending the email address and name of every new user to Twenty. Step 10 has the details.
  • Valkey is an open-source fork of Redis, which Twenty uses for its job queue and cache. noeviction keeps it from dropping queued jobs when it runs low on memory, as Twenty asks.
  • Files you attach and pictures you upload go in the twenty-files volume; everything else is in the database.
  • The tag v2 follows every release of Twenty 2. PostgreSQL stays on 16, the version Twenty's own setup uses.

Start it:

systemctl --user daemon-reload
systemctl --user start twenty-pod
systemctl --user enable --now podman-auto-update.timer

The first start downloads about 1.8 GB of images, and Twenty then takes a few minutes to set up its database. The server waits for the database, and the worker for the server. Twenty is ready when this prints active:

systemctl --user is-active twenty-worker

4. Create your account over an SSH tunnel

Until a workspace exists, whoever opens Twenty first creates it and becomes its admin. Create yours now, while Twenty can only be reached from the server itself.

On your own computer, open a tunnel to the server, and keep it open:

ssh -L 8117:127.0.0.1:8117 root@203.0.113.10

Open http://localhost:8117 in your browser, and choose Continue with Email. Enter your email address, choose Continue, then a password of at least 8 characters, and Sign up. Twenty then asks for a Name for your workspace, such as your company's name, and Create workspace. If it offers an app to install, choose Skip. Enter your First Name and Last name, choose Continue, and at Invite your team choose Skip, then Skip anyway.

Twenty opens your workspace, with five example companies, such as Airbnb and Stripe, and people and deals to go with them. Your account is the workspace's admin, and the only one with the Admin Panel. Close the tunnel with exit.

5. Put Caddy in front

As twenty, give Twenty its public address, and restart it:

cd ~/.config/containers/systemd
sed -i 's|http://localhost:8117|https://crm.example.com|' twenty-server.container twenty-worker.container
systemctl --user daemon-reload
systemctl --user restart twenty-server twenty-worker

The restart takes about two minutes: the server checks its database each time it starts.

Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:

crm.example.com {
    reverse_proxy 127.0.0.1:8117
}

Restart Caddy with systemctl --user restart caddy, open https://crm.example.com, and log in with your email address and password.

6. Check who can get in

With one workspace in place, nobody can sign up on their own: an address without an invitation gets "User does not have access to this workspace". People join in two ways, both under Settings > Members > Invite:

  • Invite by email sends an invitation to an address, valid for 30 days. Until email works (step 9), the invitation is not sent, but its link is in the worker's log.
  • Invite by link is a link that lets anyone who has it join. If you do not use it, switch off Invite by Link under Settings > General > Security. The link then answers "You do not have permission to perform this action."

New members get the Member role: they see and change all records, but the workspace settings are not in their menu. Settings > Members > Roles shows what each role may do, and you can make roles of your own.

Under Settings > Profile, each user can switch on Two Factor Authentication with an authenticator app, and see their Devices: the browsers that are logged in, with the address each was last used from. Settings > General > Security can require two-factor authentication for everyone.

7. Companies, people and deals

The menu on the left has your records: Companies, People, Opportunities (your deals), Tasks and Notes. To start:

  1. Delete the examples: open Companies, tick the box at the top of the list to select all, open the menu with ⋮ at the top right, and choose Delete Companies. Do the same under People and Opportunities. The same menu has See deleted Companies, should you need one back.
  2. Companies > Create, type the name, such as Björkvägen Bygg AB, and press Enter. The record opens on the right, where you fill in its fields.
  3. People > Create, and type the first name, Tab, and the last name. On the person's record, the pencil next to Company links them to a company.
  4. Opportunities > Create adds a deal, with an Amount, a Stage and a Close date. Choose All Opportunities above the list, then By Stage, to see your deals as a board with a column for each stage.

Each record has tabs for its Timeline and Tasks, and under More its Notes and Files, where Add file attaches a file from your computer. The search at the top left finds records by name, and notes by their text.

Under Settings > Data model, you can add fields to any object, such as an organisation number on companies, and create objects of your own. Settings > Experience sets your time zone and date format.

Twenty comes with an active workflow, Create company when adding a new person. It runs every time a person is created or changed, and its run log under Workflows > Workflow Runs keeps a copy of the whole record, with name, email address and job title. That matters when someone asks you to delete their data. Deleting the person, even with Permanently destroy Person under See deleted People, leaves the copy. Nobody can delete a run by hand, not even an admin: Twenty answers "You do not have permission to perform this workflow action". By Twenty's code, it removes finished runs itself once they are 14 days old; we did not wait to see it. With this guide's settings, the workflow also fails at its first step and creates no companies, because its code steps need a feature that is off by default. Unless you use it, open Workflows > Workflows > Create company when adding a new person and choose Deactivate. After that, new and changed people no longer end up in the run log. Emails imported from a connected mailbox (step 9) also stay when the person is deleted. Add their address to the Blocklist under Settings > Accounts, and Twenty deletes the emails it imported from that address.

8. Use Twenty in Swedish

Twenty comes translated into Swedish. A user whose browser is set to Swedish gets it from their first login; anyone else picks Swedish under Settings > Experience > Language. The menus, buttons and the names of the standard objects then change, so Companies becomes Företag and Opportunities Affärsmöjligheter.

9. Email

Twenty sends email for invitations, password resets and the Change Password link in each profile. This guide does not set up email, and without it, Twenty writes each email to the worker's log instead of sending it. Nothing shows an error: Forgot your password? answers "If this email is registered, a password reset link has been sent", and an invitation shows as sent in the list. To find the link, as twenty:

podman logs twenty-worker 2>&1 | grep -E 'Sent email to|https://[^ ]*/(invite|reset-password)/' | grep -vE '<|href'

To send email, point Twenty at your email provider's SMTP server. As twenty, store the SMTP password as a secret:

printf %s 'the-smtp-password' | podman secret create twenty-smtp-password -

Then add these lines to both twenty-server.container and twenty-worker.container, under the other Secret= lines, with your own server, account and sender address:

Environment=EMAIL_DRIVER=SMTP EMAIL_SMTP_HOST=smtp.example.com EMAIL_SMTP_PORT=587
Environment=EMAIL_SMTP_USER=crm@example.com EMAIL_FROM_ADDRESS=crm@example.com "EMAIL_FROM_NAME=Exempel AB"
Secret=twenty-smtp-password,type=env,target=EMAIL_SMTP_PASSWORD

Restart as in step 5, and try Forgot your password? on the login page. On port 587, Twenty switches to TLS with STARTTLS when the server offers it, as email providers do; for port 465, which uses TLS from the start, change the port. If no email arrives, podman logs twenty-worker shows the error. We checked these settings against a test server on the same machine, without TLS, not with a real email provider.

Each user can also connect their own mailbox, so that their emails show on the records of the people involved. Under Settings > Accounts, choose Connect via IMAP/SMTP, and enter the mailbox's IMAP server, for reading, and its SMTP server, for sending, each with its port and Connection security. Twenty tests both before it saves. On the next page, choose what other users may see of each email (Metadata, the default, shows only the time and who took part) and which people Twenty creates by itself, then Add account. We connected a mailbox on a test mail server, and an email from a customer was imported and showed up with that person. Gmail and Microsoft 365 get buttons of their own there, but only with an app registered with Google or Microsoft, which is not covered here.

Twenty refuses mail servers on private addresses, such as 192.168.1.10, so that users cannot reach other machines on your network through it. To connect one, add a line with its name or address to both twenty-server.container and twenty-worker.container, separate several with commas, and restart as in step 5:

Environment=OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS=mail.internal.example.com

A mail server on a public address, as at email providers, needs nothing.

10. Outside services

With the settings from step 3, we captured the server's DNS lookups and outgoing connections while Twenty started, sat idle, and while users signed up and records were created. What we saw:

  • Telemetry. By default, every time a user signs up, the server sends their email address, name and workspace, and your server's address, to twenty-telemetry.com, run by Twenty. With TELEMETRY_ENABLED=false it sent nothing. With the line removed, our capture showed a lookup of twenty-telemetry.com at the next sign-up.
  • The app catalog. When the worker starts, and every hour after, it fetches the list of apps for Settings > Apps from registry.npmjs.org, the npm package registry run by GitHub, and unpkg.com, a public mirror of it. It sends nothing about your data. There is no setting to switch it off.

The browser makes some requests of its own:

  • Company logos load from twenty-icons.com, run by Twenty, which gets the domain name of each company you look at.
  • Each time Twenty loads, it fetches its own logo from twentyhq.github.io, on GitHub, even after you upload a logo for your workspace under Settings > General.
  • The emails Twenty sends show its logo from app.twenty.com, which loads when the recipient opens the email.

Webhooks, under Settings > MCP & APIs, call the addresses you give them, and the AI features need a key from an AI provider, which this guide does not set up.

A webhook to another app on this same server, such as n8n, reached Twenty itself instead, and an AddHost line did not help, since Twenty refuses the private address it gives. Use the second fix in step 8 of the Podman guide: as twenty, create ~/.config/containers/containers.conf as shown there, change TRUST_PROXY=203.0.113.10 in twenty-server.container to TRUST_PROXY=10.0.2.0, since Caddy's connections then come from that address, and restart:

systemctl --user daemon-reload
systemctl --user restart twenty-pod

In our test, the webhook then arrived, and the list of devices still showed each visitor's real address.

11. Back up

As twenty:

umask 077
mkdir -p ~/backup
podman exec twenty-db pg_dump -U twenty twenty > ~/backup/twenty-db.sql
podman volume export twenty-files > ~/backup/twenty-files.tar
podman secret inspect --showsecret --format '{{.SecretData}}' twenty-encryption-key > ~/backup/encryption-key.txt

That is the database, with all your records, users and settings, the attached files, and the encryption key. The dump runs while Twenty keeps working. Copy ~/backup to another machine, for example with restic, and store it safely: it holds all your customer data and the key.

To restore into an empty pod, as twenty, with the backup in ~/backup:

systemctl --user stop twenty-pod
podman volume rm twenty-db twenty-files
podman volume create twenty-files
podman volume import twenty-files ~/backup/twenty-files.tar
systemctl --user start twenty-db
podman exec -i twenty-db psql -U twenty -d twenty -q -o /dev/null < ~/backup/twenty-db.sql
systemctl --user start twenty-server twenty-worker

Starting only the database first loads the backup before Twenty sets up an empty one. The psql line prints nothing when all goes well. On a new server, create the key from the backup instead of a new one in step 2, and skip step 4:

tr -d '\n' < ~/backup/encryption-key.txt | podman secret create twenty-encryption-key -

12. Update

podman auto-update checks once a day for new images of Twenty, PostgreSQL and Valkey, and restarts the pod when one has changed. Twenty updates its database when the server starts, which takes a few minutes after an update. To update now:

podman auto-update

Twenty can skip versions: from 2.45.8, our update went straight to 2.46.0. To see that the database is up to date afterwards:

podman exec twenty-server yarn command:prod upgrade:status 2>&1 | grep Workspaces:

It prints Workspaces: 1 up to date, 0 behind, 0 failed (1 total). If you would rather update by hand, replace v2 in both twenty-server.container and twenty-worker.container with a version, such as v2.46.0, remove their AutoUpdate=registry lines, and change the version when you want to update. Either way, take a backup first, and read the release notes on GitHub before larger updates. A move to Twenty 3 will need a new tag.

Troubleshooting

systemctl --user start twenty-pod returns, but the page does not load. Twenty is still setting up. systemctl --user is-active twenty-worker prints active when it is ready, and journalctl --user -u twenty-server -f shows what the server is doing.

Invite links start with http://localhost:8117. SERVER_URL is still the one for the tunnel. Do the first part of step 5.

Every device in the profile has the server's own address. TRUST_PROXY is missing or names the wrong address. It must be the server's IPv4 address, not 127.0.0.1, or 10.0.2.0 if the twenty user has the containers.conf from step 10. Fix it in twenty-server.container, then daemon-reload and restart as in step 5. Logins through the SSH tunnel always show the server's address.

Connecting a mailbox fails with "The server address you entered is not allowed". The mail server has a private address. Allow it with OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS, as step 9 shows. Addresses that start with 169.254. are refused even when listed, and so is host.containers.internal, which points at one of them. For a mail server on the same machine, use another of the server's addresses, and list it if it is private.

An invited colleague gets no email. Email is not set up: the link is in the worker's log, as step 9 shows. With email set up, podman logs twenty-worker shows the SMTP error.

Run it on your own server

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy

€8.99/mo

vCPU
2
Memory
8 GB
Storage
60 GB
Transfer
10 TB
Performance
SSD · RAID 1
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides