Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host Chatwoot, the open-source customer support inbox, on a VPS with the official Docker images pinned to a release, PostgreSQL with pgvector, Redis and Sidekiq, Caddy HTTPS and tested backups.
Chatwoot is an open-source customer support platform: one shared inbox that collects conversations from a live-chat widget on your website, email, WhatsApp, Facebook, Instagram, Telegram, SMS and an API, so a team answers customers from one place instead of five. It also ships canned responses, labels, automations, a help-center portal and reporting. It is written in Ruby on Rails with a Vue front end and PostgreSQL, and is rated 3 / 5 to deploy. If you are comparing it with hosted products, the closest are Intercom and Zendesk; for the closest self-hosted rival, read Chatwoot vs Zammad.
A word on the license, because "MIT" is the headline but not the whole story.
The repository's code is MIT except for everything under its enterprise/
directory, which is covered by a separate Chatwoot license and holds the
paid-tier features such as SLA policies, custom roles, SSO and the Captain AI
assistant. The regular image contains that directory; a -ce image tag is
the MIT-only build with it removed. This guide uses the standard image,
which is what most people run, and mentions the -ce tag where the choice
matters.
This guide goes from "I have a server" to Chatwoot on HTTPS with the first administrator created and a backup you have restored. For help choosing a provider and tier, see Best VPS for self-hosting.
Chatwoot's catalog entry lists 4 GB of RAM as the minimum, and that is the floor this guide assumes. Four processes share it. The Rails web server and the Sidekiq background worker are each a full Ruby application that loads the whole codebase, and each one can take several hundred megabytes on its own. PostgreSQL and Redis sit beside them. We have no idle-memory measurement for Chatwoot in the catalog, so the figures here come from our own verification run: right after a clean start, with no conversations, Rails used about 390 MB, Sidekiq about 435 MB, PostgreSQL about 90 MB, Redis about 6 MB and Caddy about 16 MB, roughly 940 MB in total. Both Ruby processes grow with real traffic, which is why 4 GB is the starting point and not 1 GB.
Memory is the first thing to go wrong, in a predictable way: Sidekiq is killed by the kernel's out-of-memory handler, jobs stop, and the symptom is that incoming emails and notifications silently stop arriving while the website still loads. If you share the box with other apps, pick the tier above 4 GB. Disk grows with conversation history and the attachments customers upload, which live in a Docker volume.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils
If you're logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the reverse proxy needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
Chatwoot will be reachable only through the reverse proxy. On a Docker host a
published container port goes around ufw, so the official Compose file binds
Rails, PostgreSQL and Redis to 127.0.0.1, and Caddy becomes the only public
door.
Paid link — we earn a commission if you shop through it.
If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version
The group change applies on your next login; until then the commands below use
sudo docker.
Chatwoot's documentation points at two files in its repository: an example
.env and a production Compose file. Download both from the same release
tag, so the configuration matches the image you will run. The retries are
there because GitHub downloads occasionally fail:
mkdir -p ~/chatwoot && cd ~/chatwoot
VER=v4.18.0
curl -fsSL --retry 5 --retry-delay 3 -o .env https://raw.githubusercontent.com/chatwoot/chatwoot/$VER/.env.example
curl -fsSL --retry 5 --retry-delay 3 -o docker-compose.yaml https://raw.githubusercontent.com/chatwoot/chatwoot/$VER/docker-compose.production.yaml
grep -c 'chatwoot/chatwoot:latest' docker-compose.yaml
The official Compose file runs chatwoot/chatwoot:latest, a moving target, and
expects you to edit the database password into it by hand. Two small edits
fix both, so an upgrade is a decision you make and the password is written
once. The first pins the image to the release; the second makes the
postgres service read its password from .env:
cd ~/chatwoot
VER=v4.18.0
sed -i "s|chatwoot/chatwoot:latest|chatwoot/chatwoot:$VER|" docker-compose.yaml
sed -i 's|^\(\s*- POSTGRES_PASSWORD=\)$|\1${POSTGRES_PASSWORD}|' docker-compose.yaml
grep -nE 'image:|POSTGRES_PASSWORD' docker-compose.yaml
If you want the MIT-only build, use $VER-ce as the image tag. Now fill in
.env. Set your hostname first (this guide uses support.example.com; replace
it with yours). The secrets are generated here and never typed:
cd ~/chatwoot
DOMAIN=support.example.com
sed -i \
-e "s|^SECRET_KEY_BASE=.*|SECRET_KEY_BASE=$(openssl rand -hex 64)|" \
-e "s|^FRONTEND_URL=.*|FRONTEND_URL=https://$DOMAIN|" \
-e "s|^FORCE_SSL=.*|FORCE_SSL=true|" \
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 16)|" \
-e "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -hex 16)|" \
.env
echo "DOMAIN=$DOMAIN" >> .env
chmod 600 .env
grep -E '^(FRONTEND_URL|FORCE_SSL|ENABLE_ACCOUNT_SIGNUP|DOMAIN)=' .env
grep -cE '^(SECRET_KEY_BASE|POSTGRES_PASSWORD|REDIS_PASSWORD)=.{16,}' .env
The last line should print 3. FRONTEND_URL is the address Chatwoot puts in
every link it generates (invitations, password resets, the widget script), so it
must be the public https address. ENABLE_ACCOUNT_SIGNUP=false is already the
default in this file, which is what you want: anyone with the address cannot
create their own account on your server. Keep the .env safe, since it holds
the keys that protect your data. One more trap: do not source this file in a
shell. The example's MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com> line
is valid for Docker but a syntax error for bash, which is why the commands in
this guide read the hostname with grep instead.
Add Caddy in front. Compose loads a file named docker-compose.override.yml
next to the main file, so the official file stays close to upstream:
cd ~/chatwoot
cat > docker-compose.override.yml <<'EOF'
services:
caddy:
image: caddy:2
restart: always
ports: ["80:80", "443:443"]
environment:
DOMAIN: ${DOMAIN}
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
depends_on: [rails]
volumes:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
encode zstd gzip
reverse_proxy rails:3000
}
EOF
sudo docker compose config --quiet && echo "compose config valid"
The database has to be prepared before the application starts, as a one-time step: it creates the schema and loads the seed data. Start only PostgreSQL and Redis first and wait until the database accepts connections, because running the prepare command against a database that is still initialising fails with a connection error:
cd ~/chatwoot
sudo docker compose up -d postgres redis
for i in $(seq 1 40); do
sudo docker compose exec -T postgres pg_isready -U postgres -d chatwoot >/dev/null 2>&1 && break
sleep 2
done
sudo docker compose exec -T postgres pg_isready -U postgres -d chatwoot
Now run the prepare job. The first run pulls the application image, which is large, so it takes a few minutes:
cd ~/chatwoot
sudo docker compose run --rm rails bundle exec rails db:chatwoot_prepare
Start everything and wait for Rails to answer. The loop is bounded so a broken install fails instead of hanging:
cd ~/chatwoot
sudo docker compose up -d
code=000
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api)
[ "$code" = "200" ] && break
sleep 3
done
echo "Chatwoot answered HTTP $code"
curl -s http://127.0.0.1:3000/api; echo
sudo docker compose ps -a --format 'table {{.Service}}\t{{.Status}}'
Do not be alarmed by a base service showing Exited (0) in that list. It is
the shared template the Rails and Sidekiq services inherit from, Compose starts
it once, and it exits immediately by design.
Create an A record for your hostname (say support.example.com) pointing
at the server's public IP, and confirm it resolves before Caddy tries to get a
certificate:
dig +short support.example.com
Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:
DOMAIN=$(grep '^DOMAIN=' ~/chatwoot/.env | cut -d= -f2)
out=000
for i in $(seq 1 20); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/api") && [ "${out%% *}" = "200" ] && break
sleep 5
done
echo "HTTPS status and certificate check: $out"
[ "${out%% *}" = "200" ] || false
A second number of 0 means the certificate verified. Do this before
creating the administrator, so every link Chatwoot generates uses the https
address from the start.
On a fresh install nobody has an account yet, and the first visit to the site starts a short onboarding form that creates the first account and its super administrator. Anyone who reaches the address before you can claim it, so do this right after HTTPS works. First confirm that the instance is waiting for onboarding:
DOMAIN=$(grep '^DOMAIN=' ~/chatwoot/.env | cut -d= -f2)
curl -s -o /dev/null -w 'start page: HTTP %{http_code} -> %{redirect_url}\n' "https://$DOMAIN/"
Expect a redirect to /installation/onboarding. Open
https://support.example.com in a browser, give your name, an account name, an
email address and a strong password. Then:
FRONTEND_URL must be right..env points SMTP at a local test
port, so invitations, password resets and email replies do not send until you
set SMTP_ADDRESS, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, MAILER_SENDER_EMAIL
and SMTP_DOMAIN in .env, then run sudo docker compose up -d.ENABLE_ACCOUNT_SIGNUP=false
unless you are running Chatwoot for several separate customers.We confirmed the install, the database preparation, the certificate and the API in our own run. We did not click through the browser onboarding, connect an inbox or send email, so test those before you point customers at it.
Dump the right database. The official Compose file creates a database named
chatwoot (its POSTGRES_DB setting), but the Rails application does not use
it. With no POSTGRES_DATABASE set, Chatwoot's production configuration falls
back to a database called chatwoot_production, which db:chatwoot_prepare
creates on first run. So pg_dump chatwoot succeeds, produces a tiny valid
file and backs up nothing; we hit this on our first backup run, where the
restore test came back with zero tables. Every command below uses
chatwoot_production.
State lives in three places: the PostgreSQL database, the storage_data
volume with uploaded attachments, and your .env. Rails uses SECRET_KEY_BASE to sign sessions and tokens, so keep the exact
value in .env with the backup; a restore with a new value signs everyone out. These archives
hold your customers' conversations, so they are created readable by their owner
only. Start with the database dump:
cd ~/chatwoot
( umask 077; sudo docker compose exec -T postgres pg_dump -U postgres -Fc chatwoot_production > ~/chatwoot-db-$(date +%F).dump )
test -s ~/chatwoot-db-$(date +%F).dump && ls -lh ~/chatwoot-db-*.dump
Then archive the attachments volume and the configuration:
cd ~/chatwoot
VOL=$(sudo docker volume ls -q | grep -E '(^|_)storage_data$' | head -1)
( umask 077
sudo docker run --rm -v "$VOL":/data:ro alpine tar czf - -C /data . > ~/chatwoot-storage-$(date +%F).tar.gz
tar czf ~/chatwoot-config-$(date +%F).tar.gz docker-compose.yaml docker-compose.override.yml Caddyfile .env )
ls -lh ~/chatwoot-storage-*.tar.gz ~/chatwoot-config-*.tar.gz
Copy these files off the server and run the set from cron. A backup you have never restored is a guess, so prove the dump loads. This starts a throwaway PostgreSQL container with the same pgvector image, restores the dump into it and counts the tables:
cd ~
sudo docker rm -f chatwoot-restore-test >/dev/null 2>&1
sudo docker run -d --name chatwoot-restore-test -e POSTGRES_PASSWORD=scratch pgvector/pgvector:pg16 >/dev/null
for i in $(seq 1 40); do
sudo docker exec chatwoot-restore-test pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && break
sleep 2
done
sudo docker exec chatwoot-restore-test createdb -U postgres chatwoot_production
sudo docker exec -i chatwoot-restore-test pg_restore -U postgres -d chatwoot_production --no-owner < ~/chatwoot-db-$(date +%F).dump
n=$(sudo docker exec chatwoot-restore-test psql -U postgres -d chatwoot_production -At \
-c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
sudo docker rm -f chatwoot-restore-test >/dev/null
echo "tables restored: $n"
[ "$n" -gt 50 ]
It should print the table count of a real schema (we saw 102), and the last
line makes the block fail if the count is 50 or fewer. The check proves the dump loads and
the schema is intact; it does not start Chatwoot against the restored database
or check the attachments archive. For a real restore onto a new server, install
Docker, unpack the config archive into ~/chatwoot, start only PostgreSQL
(sudo docker compose up -d postgres), pg_restore the dump into it, restore
the attachments into the volume, then start everything.
Because the image tag is pinned, an upgrade is an edit. Read the
release notes, take a backup
(previous section), change the tag on both chatwoot/chatwoot image
lines, which are the Rails and Sidekiq services sharing one definition, then
re-run the database migrations before the new code serves traffic:
cd ~/chatwoot
sed -i 's|chatwoot/chatwoot:v4.18.0|chatwoot/chatwoot:vNEW|' docker-compose.yaml # vNEW = the release you chose
sudo docker compose pull
sudo docker compose run --rm rails bundle exec rails db:chatwoot_prepare
sudo docker compose up -d
sudo docker compose logs --tail 20 rails
Migrations are one-way, which is why the backup comes first. Keep the
pgvector/pgvector:pg16 tag fixed: a new PostgreSQL major version is a
dump-and-restore job, not a tag bump.
The site returns 502 through Caddy, or never answers. Run
sudo docker compose ps and sudo docker compose logs rails. Rails takes a
while to boot, and a 502 right after start is normal for the first minute. If
it keeps restarting, the usual causes are a missing database preparation step
(run db:chatwoot_prepare again) and a database password in .env that no
longer matches the one the database volume was created with.
db:chatwoot_prepare fails with a connection error. PostgreSQL was still
starting. Wait for pg_isready to succeed, as in the install step, and run the
command again.
Emails and notifications stop arriving, but the site works. Sidekiq is down.
Check sudo docker compose ps for the sidekiq service and
dmesg | grep -i oom for an out-of-memory kill. The server is too small; move
up a tier instead of adding swap to a database host.
Links in emails and the widget point to 0.0.0.0 or http. FRONTEND_URL
in .env was left at its example value. Set it to the public https address
and run sudo docker compose up -d.
Caddy never gets a certificate. Confirm dig +short support.example.com
returns the server's IP and that your provider's network-level firewall, not
just ufw, allows inbound 80 and 443. A blocked port 80 silently breaks the
ACME challenge.
You're done when https://support.example.com loads over a valid certificate,
you can sign in as the administrator, a test message from the widget on a page
you control appears in the inbox, sudo docker compose ps shows every service
up after a reboot (the restart policies bring them back), and you have restored
a backup onto a scratch box at least once.
For the memory figure behind the sizing section, run this once the stack has settled:
sudo docker stats --no-stream --format '{{.Name}} {{.MemUsage}}'
From here, connect your channels, write canned responses, set up the help center, and read the Chatwoot entry for catalog details. The ranked server picks are in Best VPS for self-hosting.