Chatwoot is an open-source shared inbox — website live chat, email, WhatsApp, Telegram, Instagram and more, answered by a team from one dashboard. The self-hosted Community Edition is MIT-licensed, so there's no per-agent fee; you pay for the machine it runs on.
"Self-host it" usually means renting a VPS, running Docker Compose, putting a reverse proxy in front and owning the patching. This guide is the other route: running it on Railway, a usage-billed platform where you get containers, managed volumes and private networking without a server to look after.
The click-to-deploy part takes two minutes. The part worth reading is what doesn't work out of the box — because Chatwoot will look healthy while email, attachments or background jobs are silently broken.
What Chatwoot actually needs
Chatwoot is a Rails app with four moving parts:
| Component | Job | What happens if it's missing |
|---|---|---|
| Rails web server | Dashboard, API, the chat widget's websocket | Nothing loads |
| Sidekiq worker | Outbound email, webhooks, automation rules, reports, channel syncs | UI works, but replies to email/social channels and every notification silently stop |
| PostgreSQL with pgvector | All conversations, contacts, settings | — |
| Redis | Sidekiq's job queue and ActionCable pub/sub | Nothing works |
Two details people trip on:
pgvector is mandatory since v4. The migration that creates the Captain (AI) tables enables the vector extension, and if it can't, it stops with "Failed to enable 'vector' extension" — on a plain Postgres image your first deploy simply never finishes. Railway's managed Postgres image (ghcr.io/railwayapp-templates/postgres-ssl) ships pgvector 0.8, so it works without a custom database.
The web server and the worker are the same image. The official Docker Compose file runs it twice: once as rails s, once as sidekiq. You can do the same on Railway (two services), or run both processes in one container to halve the idle cost. More on that trade-off below.
Decision 1: Outbound email — check your plan first
This is the one that catches most people, and it's not a Chatwoot bug.
Chatwoot sends mail through SMTP only. Look at config/initializers/mailer.rb: the delivery method is :smtp, falling back to sendmail if SMTP_ADDRESS is empty. There is no HTTP-API mail driver.
Railway blocks outbound SMTP (ports 25, 465, 587, 2525) on the Free, Trial and Hobby plans. It's a platform-wide anti-spam policy, documented under outbound networking, and support won't allowlist individual projects. On those plans, connections to SendGrid, Postmark, Gmail or any other SMTP host time out before they leave Railway.
What that breaks in Chatwoot:
- Agent invites. New agents get a confirmation email to set their password. No email, no way in.
- Password resets.
- Email notifications to agents.
- Replies on an Email inbox. Chatwoot can receive email (IMAP or forwarding), but replies go out over SMTP — including when you configure per-inbox SMTP settings.
What still works without email: the live-chat widget, WhatsApp, Telegram, Instagram/Facebook, the API — anything that isn't sending mail.
Your options:
- Use the Pro plan if email matters to you (it does for almost any real support team). SMTP is enabled on Pro and above.
- Stay on Hobby and skip email, e.g. if you only use the website widget or WhatsApp. You can still create agents without the invite email by setting their password from a Rails console:
railway ssh # into the Chatwoot service
bundle exec rails runner '
u = User.find_by(email: "agent@example.com")
u.update!(password: "a-long-temporary-password", confirmed_at: Time.current)'
Have them change the password after their first login.
Either way, decide this before you invite your team, not after the first "I never got the email" message.
Decision 2: Where attachments live
Every file a customer sends through the widget, every agent avatar, every email attachment goes through Rails Active Storage. Chatwoot picks the backend from ACTIVE_STORAGE_SERVICE, and in production it defaults to local — which means /app/storage inside the container.
On any container platform, the container filesystem is thrown away on every redeploy. If nothing is mounted at /app/storage, conversations survive (they're in Postgres) but every attachment and avatar disappears the next time the service restarts, leaving broken image links throughout your history.
Two correct setups:
A. A volume at /app/storage (simplest). Attach a Railway volume to the Chatwoot service with mount path /app/storage, and leave ACTIVE_STORAGE_SERVICE=local. Volume storage on Railway is billed per GB-month (about $0.16 at current rates) and can be included in Railway's scheduled backups (below). The limitation: a volume attaches to one service, so this only works if the web server and Sidekiq run in the same container — which is another reason for the single-container layout.
B. An S3-compatible bucket (better once you have real volume, or if you split web and worker into separate services). Chatwoot's config/storage.yml defines two relevant backends, and the variable names matter — I've seen guides get these wrong:
# AWS S3
ACTIVE_STORAGE_SERVICE=amazon
S3_BUCKET_NAME=your-bucket
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=us-east-1
# Anything S3-compatible (Cloudflare R2, MinIO, DigitalOcean Spaces, ...)
ACTIVE_STORAGE_SERVICE=s3_compatible
STORAGE_BUCKET_NAME=your-bucket
STORAGE_ACCESS_KEY_ID=...
STORAGE_SECRET_ACCESS_KEY=...
STORAGE_REGION=auto
STORAGE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
STORAGE_FORCE_PATH_STYLE=true
Set them on every service that runs the Chatwoot image. Note that switching backends later doesn't move existing files — pick one before you have history worth keeping.
Decision 3: One container or two
Running Rails and Sidekiq in one container saves memory and makes option A storage possible. The cost is that you can't scale or restart them independently, and you need something to keep Sidekiq alive.
That last part is subtle. The obvious entrypoint script —
#!/bin/sh
set -e
bundle exec sidekiq -C config/sidekiq.yml &
exec bundle exec rails s -b 0.0.0.0
— has a nasty failure mode: if Sidekiq crashes, the web server keeps answering health checks, so the platform thinks everything is fine while every background job has stopped. Wrapping Sidekiq in a while true restart loop fixes it, but only if the loop is immune to set -e. In a POSIX shell with set -e, a backgrounded function exits on the first non-zero command — so a loop like this dies on the first crash and never restarts anything:
set -e
run_sidekiq() {
while true; do
bundle exec sidekiq -C config/sidekiq.yml # non-zero exit kills the loop here
echo "restarting" # never reached
sleep 5
done
}
run_sidekiq &
The fix is one change — capture the exit code so the shell doesn't treat it as fatal:
run_sidekiq() {
while true; do
code=0
bundle exec sidekiq -C config/sidekiq.yml || code=$?
echo "[chatwoot] Sidekiq exited with code $code, restarting in 5s"
sleep 5
done
}
You can check this yourself in ten seconds: put sh -c 'exit 3' in place of the Sidekiq command and run the script with and without || code=$?.
When to split into two services: once Sidekiq queues back up during busy hours (Chatwoot's Sidekiq dashboard at /monitoring/sidekiq shows queue latency for super-admins), or once you want zero-downtime web deploys. Create a second service from the same image with the start command bundle exec sidekiq -C config/sidekiq.yml, give it the same environment variables, and move attachments to option B, since the volume can't be shared.
Deploying it
I maintain a Railway template that wires this up: Chatwoot (web + Sidekiq in one container), Railway Postgres with pgvector, and Redis with persistence. Database migrations run on boot.
(Disclosure: I earn a share of Railway usage from deployments of this template. Everything above applies equally if you build the services yourself.)
After clicking deploy:
-
Check storage. Make sure the Chatwoot service has a volume mounted at
/app/storage(add one under the service's settings if not), or set the S3 variables from decision 2. -
Set
SECRET_KEY_BASE. Generate it withopenssl rand -hex 64. Don't change it later — it signs sessions and encrypts stored credentials. -
Wait for the first boot.
db:chatwoot_preparecreates and migrates the database before the web server binds its port. On a cold database this can take several minutes, which is why the health check timeout is set to 900 seconds; a shorter timeout kills the deploy mid-migration and leaves a half-migrated database. -
Open the public URL and create the admin account. A fresh install shows an onboarding form that creates the super-admin user — do this immediately, before anyone else finds the URL. Make sure
ENABLE_ACCOUNT_SIGNUP=falseso strangers can't create their own accounts. -
Check
FRONTEND_URL. It defaults to the Railway domain. If you add a custom domain, update it — Chatwoot builds the widget script URL and every link in outgoing email from this value, so a stale one gives you a widget that won't load and password-reset links to a dead host. - Configure email per decision 1 (Pro plan + SMTP variables).
- Create an inbox, paste the widget snippet into your site, and send yourself a test message — including an attachment, then redeploy and confirm the attachment is still there.
Backups
Your data lives in two places: Postgres (conversations, contacts, settings) and the /app/storage volume (files), if you chose option A.
- Railway volume backups. Each volume's Backups tab lets you schedule daily (kept 6 days), weekly (kept 27 days) and monthly (kept 89 days) backups. They're incremental, billed for the unique data they hold, and restored with one click. Turn this on for both the Postgres volume and the storage volume.
-
Keep one copy off-platform. Railway backups can only be restored inside the same project and environment, so they don't protect you from deleting the project. A periodic
pg_dumpto somewhere else covers that:
pg_dump "$DATABASE_PUBLIC_URL" -Fc -f chatwoot-$(date +%F).dump
Use a pg_dump client whose major version is at least the server's (18 for the template's Postgres).
- Redis holds queued jobs, not your data. The template's Redis snapshots every 60 seconds when anything changes, so a crash loses at most about a minute of not-yet-run jobs.
Upgrades
The chatwoot/chatwoot:latest tag moves with every release, and Chatwoot runs migrations automatically at boot — so on latest, any redeploy (including one you triggered for an unrelated variable change) can upgrade you to a new version without warning.
For a support tool your team relies on, pin a version instead: change the Dockerfile's FROM line to a release tag (v4.19.1 at the time of writing), read the release notes before bumping it, and take a manual volume backup of Postgres first. Downgrades after a migration are not supported.
What it costs
Railway bills per second for what the containers actually use: about $10 per GB of RAM per month, $20 per vCPU per month, and $0.16 per GB-month of volume storage. The Hobby plan is $5/month including $5 of usage; Pro is $20/month including $20 of usage.
Chatwoot is a memory-resident app — Rails and Sidekiq sit in RAM whether or not anyone is chatting — so RAM is most of the bill, and it's roughly constant rather than tied to traffic. Every GB that the app, Postgres and Redis hold together adds about $10/month. Check your services' Metrics tab after a week; that number is the only one that matters for your instance.
Compare that with per-seat SaaS pricing at your team size. The break-even point arrives fast with more than a couple of agents — but if you need SLAs, Captain AI features or someone else on call, Chatwoot's own cloud and premium self-hosted plans exist for that.
When not to do this
- You need email and won't pay for a Pro-level plan. On Hobby, Chatwoot can't send mail. Fix that first or pick another host.
- Nobody on your team will ever read a log. Self-hosting is cheap, not free: someone has to notice when a deploy fails or a channel token expires.
- Compliance needs a signed agreement with your vendor. Self-hosting gives you data control, not paperwork.
Checklist
- [ ] Postgres has pgvector (first boot finishes)
- [ ]
SECRET_KEY_BASEset and saved somewhere safe - [ ] Email: on a plan with SMTP, SMTP variables set, test invite received
- [ ] Storage: volume at
/app/storageor S3/R2 variables — attachment survives a redeploy - [ ] Sidekiq is supervised (or a separate service)
- [ ]
FRONTEND_URLmatches the domain you actually use - [ ] Volume backups scheduled for Postgres (and storage), plus an off-platform
pg_dump - [ ] Image pinned to a release tag
If something in here is wrong or out of date, tell me in the comments — I'd rather fix the article than have someone lose their attachments to it.
Top comments (0)