DEV Community

Amritasha Agrawal
Amritasha Agrawal

Posted on

Self-Hosting Chatwoot on Railway: What Actually Breaks (Email, Attachments, Sidekiq)

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:

  1. Use the Pro plan if email matters to you (it does for almost any real support team). SMTP is enabled on Pro and above.
  2. 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)'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

— 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 &
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Deploy on Railway

(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:

  1. 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.
  2. Set SECRET_KEY_BASE. Generate it with openssl rand -hex 64. Don't change it later — it signs sessions and encrypts stored credentials.
  3. Wait for the first boot. db:chatwoot_prepare creates 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.
  4. 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=false so strangers can't create their own accounts.
  5. 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.
  6. Configure email per decision 1 (Pro plan + SMTP variables).
  7. 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_dump to somewhere else covers that:
  pg_dump "$DATABASE_PUBLIC_URL" -Fc -f chatwoot-$(date +%F).dump
Enter fullscreen mode Exit fullscreen mode

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_BASE set and saved somewhere safe
  • [ ] Email: on a plan with SMTP, SMTP variables set, test invite received
  • [ ] Storage: volume at /app/storage or S3/R2 variables — attachment survives a redeploy
  • [ ] Sidekiq is supervised (or a separate service)
  • [ ] FRONTEND_URL matches 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)