Deploying a rails app with docker on a raspberry pi

Deploying a rails app with docker on a raspberry pi

Today there are many options available to deploy apps accessible to the world. There is aws, azure, gcp as the biggest ones. Or platform as a service companies like heroku, vercel, DigitalOcean, netlify, etc.

In my professional career I've often used these services to make deployments simple and mostly not to maintain everything that goes with it. Part of that is because I don't have a lot of expertise with the deep workings of these system. I understand at a higher level what it does, but usually I'm glad I don't have to bother with it.

Recently I've been working on a personal rails app and thought about hosting it on my old raspberry pi 2, that's been collecting dust for a while. So why not try and see what there is to learn on manually deploying a docker container on this raspberry pi device myself.

Setting up your pi

To start, lets setup our raspberry pi and ensure we have all the right packages installed.

  1. To start with a clean slate on an sd card and a fresh install of pi os, you can use this software on any os you're currently on: https://www.raspberrypi.com/software/

    Note: when copying the os to the sd drive make sure to set a username and password for ssh access. This allows for easier remote access without any other monitors or peripherals connected

  2. Ensure your os is setup and your pi connected to where you can access it through ssh

  3. Connect to your pi through ssh and lets install some packages
    In my case on macOS that would look like this

     ssh user@192.168.0.192
    

    After which it will ask for your password

    If you can't find the ip address of your pi you can usually find it in your routers interface by its name

  4. Lets make sure we have all the latest packages installed and updated. Run the command:

     sudo apt-get update && sudo apt-get upgrade
    
  5. Installing docker is luckily a pretty simple script to download and run:

     curl -fsSL test.docker.com -o get-docker.sh && sh get-docker.sh
    
  6. By default, only users who have administrative privileges (root users) can run containers. However, you can also add your non-root user to the Docker group which will allow it to execute docker commands, which is what we want

    To add the permissions to the current user run:

     sudo usermod -aG docker ${USER}
    

    Check it running:

     groups ${USER}
    

    Reboot the Raspberry Pi to let the changes take effect:

     sudo shutdown -r now
    
  7. Install docker compose using the following

     sudo apt install docker-compose
    
  8. Enable the Docker system service to start your containers on boot

    This will setup your Raspberry Pi to automatically run the Docker system service whenever it boots up.

     sudo systemctl enable docker
    

You can test docker by running docker run hello-world
When successful you should see a message like "Hello from Docker!"

Deploying the rails app

To avoid installing all dependencies manually and setting up a database and other services directly on the raspberry pi, we can instead use docker. This will make for a lot more consistent and more portable app.

When you create a new rails app with rails new your_app --database=postgresql it automatically generates with a DockerFile, making life a lot easier. At the time of writing, this is what that looks like. And according to the rails docs is production ready too.

The Dockerfile

# syntax = docker/dockerfile:1

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.2.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base

# Rails app lives here
WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"


# Throw-away build stage to reduce size of final image
FROM base as build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git libpq-dev libvips pkg-config

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile


# Final stage for app image
FROM base

# Install packages needed for deployment
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libvips postgresql-client && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Copy built artifacts: gems, application
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN useradd rails --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER rails:rails

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

However, rails usually does not work on its own without a database (and redis in case you want to use some async workers like sidekiq). In our case that database is postgres.

To start rails alongside the database the simplest way is to use docker composer. Docker compose lets you combine different services and connect them together.

Create a new file in the root of your project called docker-compose.yml with the following content:

version: '3.7'
services:
  db:
    image: 'postgres:latest'
    volumes:
      - postgres:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=api_production
      - POSTGRES_USER=api
      - POSTGRES_PASSWORD=[your-password]
  web:
    environment:
      - SECRET_KEY_BASE=[some-key]
      - API_DATABASE_PASSWORD=[your-password]
    build: ./
    ports:
      - '3000:3000'
    volumes:
      - .:/docker/app
    depends_on:
      - db
volumes:
  postgres: # named volume

One last thing to update is our production database connection in rails in database.yml and make sure the production key looks like this (or development if you want to use docker for development)

production:
  <<: *default
  database: api_production
  username: api
  password: <%= ENV["API_DATABASE_PASSWORD"] %>
  host: db # this links the docker database service

One last thing we need to do in order to build on the raspberry pi 2 is to add this line in the Gemfile.loc. This section allows it to be build on that specific os. If you're using a different os and raspberry pi, you might need a different one. But it will give you the appropriate error message

PLATFORMS
  arm-linux-eabihf

After this is all setup we can run the following:

docker compose up --build

The docker command should automatically detect the docker-compose.yml file and start building the image. The --build argument ensure that it rebuilds in case there's any changes to the ruby code.

The first time this will take a while and will download several containers. After the first time, those should be cached and will be significantly faster.

If you want to run the docker image detached you can add a -d to the command to run it detached and keep the image running when disconnecting from the pi.

You should be able to access your app on the same ip as the raspberry pi with port 3000 (unless otherwise specified):

192.168.0.192:3000

Rails startup page screenshot

Cleanup

To clean up your docker build simply run

docker compose down

And that should stop the container and its services.

Closing thoughts

None of the steps included a git repository, but would be greatly helpful of course.

Most likely you would develop the app locally on your local device and test it out. Whenever you make any changes, you can push those up to your repo.

On your raspberry pi you can then pull those changes down and rerun docker compose up --build to update your image with the latest code changes and database migrations.