All posts

How to host a Rails app in Canada

Ross Hill · September 14, 2026

If you want a Rails app and its database running in Canada, you already have most of the deployment artifact. Since Rails 7.1, rails new includes Docker-related files in the application by default, and that Dockerfile is written for production. That means the containerization work is already done for you. What is left is choosing a Canadian machine that can build a Dockerfile, putting PostgreSQL beside it, and getting four or five settings right.

This walks through that on a dedicated VM in Toronto running Coolify, which is what MapleDeploy provides. Most of it applies to any Docker host you pick.

What the generated Dockerfile already does

Worth reading before you change anything, because people routinely rewrite parts that already work. In the current template:

  • It is multi-stage. Build tools and Node modules stay in the throwaway stage, and the final image runs as a non-root rails user.
  • It sets RAILS_ENV="production" and BUNDLE_WITHOUT="development", so you do not need to pass those in.
  • It precompiles assets during the build with SECRET_KEY_BASE_DUMMY=1. Your master key is not needed at build time, only at runtime.
  • Since Rails 8, the default CMD starts Thruster in front of Puma for X-Sendfile acceleration and asset caching, and the image exposes port 80 instead of 3000. Thruster only terminates TLS when you set TLS_DOMAIN, so behind a reverse proxy it stays in HTTP-only mode, which is what you want here.
  • The entrypoint runs ./bin/rails db:prepare when the container command ends in ./bin/rails server. That is true with Thruster too, since ./bin/thrust ./bin/rails server still ends in those two arguments.

That last point matters for the migration question below.

Step 1: the server and the build pack

Create your server, open Coolify, and add your repository as a new application. The generic version of this is in our guide to deploying your first app, so here are only the Rails-specific answers.

Set the build pack to Dockerfile, not Nixpacks. Nixpacks infers a build for projects that do not ship one, and it does that well. Rails already ships a production Dockerfile written for this purpose, so there is nothing to infer.

Set Ports Exposes to 80. This is a common Rails deploy failure on a Docker platform, because 3000 is the usual assumption, and Rails changed it when Thruster arrived. If you generated the app with --skip-thruster, use 3000 instead. Check the EXPOSE line in your own Dockerfile rather than trusting either number.

Step 2: PostgreSQL on the same server

Add PostgreSQL as a one-click database in the same Coolify instance. Coolify shows a Postgres URL (internal) value that resolves over the server's Docker network, so your app reaches the database without the connection ever leaving the machine. That also means one region to verify instead of two, which is the argument we make at more length in where to run managed PostgreSQL in Canada.

Paste that URL into your application's environment variables as DATABASE_URL. Rails merges it on top of config/database.yml rather than replacing the file, and the configuring guide describes exactly how that merge resolves.

Then check something people miss. If you generated a Rails 8 app with Postgres and did not pass --skip-solid, your production block is a multi-database config: primary, cache, queue, and cable, each pointing at a separate database name. DATABASE_URL only covers the primary one. Non-primary connections read their own variables, formed by joining the connection name to _DATABASE_URL:

DATABASE_URL=postgres://user:pass@host:5432/app_production
QUEUE_DATABASE_URL=postgres://user:pass@host:5432/app_production_queue
CACHE_DATABASE_URL=postgres://user:pass@host:5432/app_production_cache
CABLE_DATABASE_URL=postgres://user:pass@host:5432/app_production_cable

Create those extra databases in your Postgres instance, or trim the production block down to a single primary if you are not using the Solid adapters. Either is fine. What breaks things is deploying with three of the four unset and not noticing.

Step 3: the rest of the environment

RAILS_MASTER_KEY is the one secret the container genuinely needs. It is the contents of config/master.key, and Rails will refuse to boot without it when config.require_master_key is set. Leave it as a runtime variable. The build does not read it.

RAILS_MAX_THREADS sets the Active Record connection pool size in the generated database.yml, defaulting to 5. If you also run multiple Puma workers through WEB_CONCURRENCY, your total connection count is workers multiplied by threads, and Postgres has a finite max_connections. Do that arithmetic before you scale either number up.

Step 4: migrations

You have two reasonable options, and one that looks right and is not.

Let the entrypoint do it. db:prepare creates the database if it is missing and migrates it if it exists. For most applications this is enough, and it is already wired up.

Use Coolify's post-deployment command. In the deployment lifecycle settings, a post-deployment command runs in the newly built container after the deployment completes. bundle exec rails db:migrate there gives you a migration step you can see and re-run in the deployment log.

The trap is the pre-deployment field sitting next to it. That one executes in the existing container, before the new one starts, so it runs your migration against the old code. Use it for cache warming or a maintenance flag, not for migrations that ship with the release.

Step 5: background jobs

From Rails 8, Solid Queue is the default Active Job backend, and it is database-backed, so it needs no Redis. Two ways to run it:

  • Run the supervisor with bin/jobs as a second Coolify resource pointing at the same repository and image. Jobs then scale and restart independently of your web process.
  • Or set SOLID_QUEUE_IN_PUMA, which runs the supervisor inside Puma. A default Rails 8 app already generates the plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] line in config/puma.rb, so the variable is usually the only thing you add. Solid Queue's own README documents both, and recommends a separate queue database while supporting a single shared one.

Sidekiq remains the common choice for high-throughput queues, and it needs Redis: Sidekiq requires Redis 7.0+, Valkey 7.2+, or Dragonfly 1.27+. Redis and Dragonfly are one-click resources in Coolify, running as containers on the same VM, so this is a small addition rather than another vendor. Valkey is not on Coolify's one-click list, so that one needs a Docker Compose resource instead.

Step 6: domain, TLS, and the health check

Point an A record at your server's IP, put the full URL in Coolify's Domains field, and redeploy. Coolify requests the certificate from Let's Encrypt and renews it. The full version is in custom domains.

Two Rails settings are worth revisiting at this point. Since Rails 8.1, a default rails new generates config.assume_ssl and config.force_ssl commented out. Rails 8.0 generated them already enabled. Behind Coolify's proxy you generally want both on, so check config/environments/production.rb and uncomment them if your generator left them commented.

Then set the Coolify health check path to /up, which Rails routes to rails/health#show by default. If you turned on force_ssl, an HTTP health check will get a redirect instead of a 200. The generated production config ships the fix commented out one line below:

config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

One more Rails-specific gotcha: uploads

The generated config sets config.active_storage.service = :local, which writes to storage/ inside the container. Container filesystems do not survive a redeploy. Either attach a persistent storage volume to that path in Coolify, or switch Active Storage to an S3-compatible service. Pick one deliberately, because the failure mode is user uploads quietly disappearing on your next deploy rather than an error you can see.

What we manage, and what you manage

Worth being precise, because "managed" means different things on different platforms.

We manage: the VM in Toronto, the operating system and its security patching, the Coolify installation and its updates, uptime monitoring on your instance, and weekly full-server snapshots. Those snapshots are a disaster recovery layer and can be up to seven days old, so configure Coolify's S3 database backups for anything you cannot afford to lose.

You manage: your Rails code, your Dockerfile, your environment variables, your migrations, your job supervisor, your Postgres tuning, and how you divide the server's RAM and CPU among the web process, the worker, and the database.

We do not review your Dockerfile, your Rails configuration, or your dependency versions. The steps above are yours to work through, and they are the same steps you would work through on any Docker host. You get root on the VM, so those decisions stay yours to make.

Plans are flat CAD per month, starting at $45 for 4 GB of RAM and 2 vCPUs, with no per-service billing for the database or the worker. They still share the plan's RAM and CPU, so size accordingly. Starter and Pro include a 30-day free trial, long enough to deploy the real app, run a real migration, and see what your build actually needs before you pay for it. If the reason you are reading this is jurisdiction rather than pricing, our page on Canadian infrastructure covers ownership, residency, and the CLOUD Act question in full.

Put your Rails app on Canadian infrastructure

Dockerfile builds, one-click PostgreSQL, and a dedicated Toronto VM. 30 days free on Starter and Pro.