Deploy a Rails app to Raspberry Pi with Docker in 2024

The Problem

I want to deploy a Rails app in production mode to one of my spare Raspberry Pi, or any other Linux server such as a VPS from Hetzner or Digital Ocean or AWS. What is the minimum configuration?

We now have Kamal, we have Dokku, these are very good tools, but sometimes I don't have open network, maybe I'm in a local network without Internet, can I just "docker build" from my Mac, scp my docker image to my server, then load and run it on my server?

Let's try it!

The Reasoning

So, after a "rails new my-app", which ships with a default Dockerfile since Rails7, I should run

$ docker run my-app

It's the thing I know. The my-app image is the Rails app I want to build with "docker build", how should I build it? I can build it on my local development machine with:

$ cd my-app
$ docker build -t my-app .

And that's it. I now have a image, I checked with:

$ docker image ls

It gives me:

REPOSITORY      TAG               IMAGE ID       CREATED          SIZE
my-app          latest            548628d797db   43 minutes ago   554MB

Then I want to transfer this image to my Raspberry Pi, the server I want to run the app on, I can:

$ docker save my-app | bzip2 | ssh [email protected] docker load

And now I have my docker image on my server! I can check that with:

# On the pi:
$ docker image ls

And it gives me the my-app image just the same size as it was on my development machine (my mac). So that's a progress, what's next?

Simply run the command written in the Rails new Dockerfile template, and see how it goes:

(5000:3000 means, the rails app runs itself on port 3000, docker will respond to port 5000 and forward the traffic to port 3000. In another word, I set Dockerfile to run 3000, but I visit http://raspberrypi.local:5000 to open the website.)

$ docker run -p 5000:3000 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> my-app

It shows:

bin/rails aborted!
ActiveRecord::ConnectionNotEstablished: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory (ActiveRecord::ConnectionNotEstablished)

Well not so good, it complains cannot connect to postgres server. Of course! I haven't started my pg server!

Start Postgres Server

So I need to start a postgresql server, how? What is the easiest way? I went to docker hub and checked the README: https://hub.docker.com/_/postgres. Well for a postgres db I apparently need to set a user and a password, so I pass it in the Docker ENV:

docker run --name my-app-pg -e POSTGRES_PASSWORD=dumbpass -e POSTGRES_USER=my-app -d postgres:16 

The password of my pg database is "dumbpass", and I set the username to "my-app". Why "my-app"? Because it's the default pg username when rails new generates the app. I can find it in:

Inside: 'config/database.yml'

production:
  <<: *default
  database: my-app_production
  username: my-app
  password: <%= ENV["MY-APP_DATABASE_PASSWORD"] %>

That's what Rails gives me by default. Good! Postgres database is now running on my server. But how to connect to this DB from my Rails app? I figure at least I need to pass the pg password to let Rails know it?

Well I see there is a "password: <%= ENV["MY-APP_DATABASE_PASSWORD"] %>", if I pass this in the docker run command, Rails should be able to pick it up, right? Let me have a try:

$ docker run -p 5000:3000 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> -e MY-APP_DATABASE_PASSWORD=dumbpass my-app

A Docker sub Network

This time rails should know both the pg username and password, it shouldn't complain anymore, but wait? It says connection error, not authentication error, I still have problem to fix before running the rails image.

I figure I should create a virtual docker network to let the rails container and the postgres container find each other, so I ran:

$ docker network create rails_net

And now I have a sub network, I can let these two containers run in the same network, and then try to let them find each other, maybe by IP (some 172.17.xxx thing) or, maybe by name? But first I need to re-run the postgres container to let it run inside this new virtual network:

$ docker run --name my-app-pg -e POSTGRES_PASSWORD=dumbpass -e POSTGRES_USER=my-app --network rails_net -d postgres:16 

Then I can start the rails app:

$ docker run -p 5000:3000 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> -e MY-APP_DATABASE_PASSWORD=dumbpass --network rails_net my-app

But it still gives the same error, Rails still can't find where the DB is running. Of course! Rails by default looks for localhost:5432, but the DB is in another container, it's still on port 5432 but not on Rails container's localhost. So I need to tell Rails where to find the DB.

Since we know we ran the postgres db container with a name: "my-app-pg", we can use this name like a computer in the "rails_net" network. The Rails container knows it, it can use the other container's name to find that container in the Docker network.

So, we need to modify one rails file, to add a host param:

Inside: 'config/database.yml'

production:
  <<: *default
  database: my-app_production
  username: my-app
  password: <%= ENV["MY-APP_DATABASE_PASSWORD"] %>
  host: <%= ENV["DB_HOST"] %>

I don't want to hardcode the "host" to some string, but to a ENV parameter, because I know I can make mistake and I don't want to build the docker image every time I change something in the rails app. So setting it to <%= ENV["DB_HOST"] %>, then pass a ENV in the docker run command makes things easier:

$ docker run -p 5000:3000 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> -e MY-APP_DATABASE_PASSWORD=dumbpass -e DB_HOST=my-app-pg --network rails_net my-app

In the last command, I specify DB_HOST=my-app-pg, this "my-app-pg" is just the name I ran the pg container with. Looks good to me, instead of immediate quit, it is now showing:

Created database 'my-app_production'
=> Booting Puma
=> Rails 7.2.1 application starting in production 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 6.4.2 (ruby 3.2.2-p53) ("The Eagle of Durango")
*  Min threads: 3
*  Max threads: 3
*  Environment: production
*          PID: 1
* Listening on http://0.0.0.0:3000
Use Ctrl-C to stop

But rails ships with force_ssl=true by default, whenever I visit a page, it goes to "https://..." but since I don't have a valid certificate(will be in another post), it can't display anything. I can easily fix it by changing:

# In config/environments/production.rb
# Force all access to the app over SSL, use strict-Transport-Security, and use secure cookies.
# config.force_ssl = true
config.force_ssl = false

Now everything is working.

But this doesn't cover mounting a volume for postgres db, which will cause database data to lose once the container is restarted, you should be able to figure it out easily :) A quick command:

$ docker volume create pgdata

$ docker run --name my-app-pg -e POSTGRES_PASSWORD=dumbpass -e POSTGRES_USER=my-app -v pgdata:/var/lib/postgresql/data --network rails_net -d postgres:16 

Summary

In the end, I changed 2 files generated from rails new, they are:

# In config/database.yml

production:
  <<: *default
  database: my-app_production
  username: my-app
  password: <%= ENV["MY-APP_DATABASE_PASSWORD"] %>
  host: <%= ENV["DB_HOST"] %>
# In config/environments/production.rb
# Force all access to the app over SSL, use strict-Transport-Security, and use secure cookies.
# config.force_ssl = true
config.force_ssl = false

One Docker build command to run on my development machine:

$ cd my-app
$ docker build -t my-app .

One command to transfer the docker image from my development machine to the server, you can also chain a "pv" to show the progress.

$ docker save my-app | bzip2 | pv | ssh [email protected] docker load

One command to create a Docker network, one command to run a postgres db, one command to run the Rails app:

$ docker network create rails_net

$ docker volume create pgdata

$ docker run --name my-app-pg -e POSTGRES_PASSWORD=dumbpass -e POSTGRES_USER=my-app -v pgdata:/var/lib/postgresql/data --network rails_net -d postgres:16 

$ docker run -p 5000:3000 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> -e MY-APP_DATABASE_PASSWORD=dumbpass -e DB_HOST=my-app-pg --network rails_net -d my-app

This is the most simple way I can figure out to run a Rails app with Docker, maybe we can use Kamal and Dokku, but knowing the "manual" mode is very valuable as well!

Cheers!