Skip to content

Instantly share code, notes, and snippets.

@sshkarupa
Created September 13, 2017 11:00
Show Gist options
  • Save sshkarupa/0cb8a9ed6f69f8d6df34035ca078ce36 to your computer and use it in GitHub Desktop.
Save sshkarupa/0cb8a9ed6f69f8d6df34035ca078ce36 to your computer and use it in GitHub Desktop.
Creating a new Rails application project with Docker

You certainly won't need anything installed other than Docker to create Ruby apps...

1: Generating the project

The idea is to mount the current folder into a temporary Ruby container using the official Ruby image from Docker Hub, then install Rails inside this temporary container, and then create the project skeleton using the rails new command.

# Start bash inside a Ruby container:
docker run --rm -v $(pwd):/usr/src -w /usr/src -ti ruby:2.3.1 bash ; cd my_app

A quick explanation of the command flags and options for our initial docker run command:

  • --rm will remove the container and it's contents after we finish - the project skeleton will remain in our filesystem, tho, as we're...

  • -v $(pwd):/usr/src ...mounting the current directory inside the container's /usr/src directory. All of the files in our current directory will be accessible inside our temporary container at the specified /usr/src path.

  • -w /usr/src will start our container in this directory, so we won't need to navigate through the container's filesystem to do our business.

  • -ti enables the interactive mode. Mandatory if we need to issue commands and review the output when we do our stuff.

  • ruby:2.3.1 is the base image and version we're using for our temporary container.

  • bash is the command we're using to start the container.

Curious about the ; cd my_app part? That's so when we finish the following step, we'll be inside our newly created project folder...

Now, once Docker downloaded the image and started the container - and we're inside of it, and using the bash command-line prompt, we can do our business:

# We install Rails first...
gem install rails

# Then let's just make sure we're on the folder we want to create our project
# in:
ls -lah

# Did you see the files that existed in your workspace? 

# Next, let's make sure Rails was installed and can run the project create
# command:
rails new --help

# Last, we'll create the project skeleton (your options may vary) - but let's
# disable running bundler after the project creation, as it may take a couple
# of minutes, and we'll end up removing this container, so the install is of
# no use here:
rails new my_app --database=postgresql --skip-bundle

# We've finished! Let's exit the container and look for our newly generated
# project skeleton:
exit

# Now you'll notice that your'e looking to the actual project files in your
# host :)

2: Complete the project

Now we'll need to create and/or change a couple of files so we can start the services our app will use (i.e. Database, etc) and to start our app in the future. At the very least, we'll need to create/change the following files:

2.1 Create the development Dockerfile

On most of the cases, we'll need to add software apart from what is available in the official ruby image we used earlier on (i.e. add NodeJS as the javascript runtime for asset compilation via Sprockets). That's where having a Dockerfile to create our version of the base image comes handy.

Create a file named dev.Dockerfile at the root of the project:

# 1: Use ruby 2.3.1 as base:
FROM ruby:2.3.1

# 2: We'll set the application path as the working directory
WORKDIR /usr/src/app

# 3: We'll add the app's binaries path to $PATH:
ENV HOME=/usr/src/app PATH=/usr/src/app/bin:$PATH

# 4: Install node as a javascript runtime for asset compilation. Blatantly
# ripped off from the official Node Docker image's Dockerfile. GPG keys
# listed at https://github.com/nodejs/node
RUN set -ex \
  && for key in \
    9554F04D7259F04124DE6B476D5A82AC7E37093B \
    94AE36675C464D64BAFA68DD7434390BDBE9B9C5 \
    0034A06D9D9B0064CE8ADF6BF1747F4AD2306D93 \
    FD3A5288F042B6850C66B31F09FE44734EB7990E \
    71DCFD284A79C3B38668286BC97EC7A07EDE3FC1 \
    DD8F2338BAE7501E3DD5AC78C273792F7D83545D \
    B9AE9905FFD7803F25714661B63B535A4C206CA9 \
    C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \
  ; do \
    gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$key"; \
  done \
  && export NPM_CONFIG_LOGLEVEL=info \
  && export NODE_VERSION=6.3.1 \
  && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
  && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
  && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
  && grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
  && tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \
  && rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt

# 5: Install the current project gems - they can be safely changed later
# during development via `bundle install` or `bundle update`:
ADD Gemfile* /usr/src/app/
RUN set -ex && bundle install

2.1.1 Recommended: Add a .dockerignore file

Just as the .gitignore file let's us exclude files from the Git project, we can use a .dockerignore file to keep unwanted files in the project - mostly log files, undesired artifacts, etc - from making it into our image and prevent changes on these from invalidating the image cache:

Create a .dockerignore file at the root of the project:

# Ignore version control files:
.git/
.gitignore

# Ignore docker and environment files:
*Dockerfile
docker-compose*.yml
*.env
bin/entrypoint-dev
.dockerignore
bin/checkdb

# Ignore log files:
log/*.log

# Ignore temporary files:
tmp/

# Ignore test files:
.rspec
Guardfile
spec/

# Ignore OS artifacts:
**/.DS_Store

.rspec

# 3: Ignore Development container's Home artifacts:
# 3.1: Ignore bash / IRB / Byebug history files
.bash_history
.byebug_hist
.byebug_history
.pry_history
.guard_history

# 3.3: bundler stuff
.bundle/*

2.2 Create a development entrypoint (entrypoint-dev)

An 'entrypoint' in Docker-land is an executable script that is run at the container start, and it's the perfect place to - for example - check if the project's database is installed, or install it otherwise. That way, the database is created automatically when we start working on the project. This is an very robust example development entrypoint: create the entrypoint-dev file in bin folder:

#! /bin/bash
set -e

: ${APP_PATH:="/usr/src/app"}
: ${APP_TEMP_PATH:="$APP_PATH/tmp"}
: ${APP_SETUP_LOCK:="$APP_TEMP_PATH/setup.lock"}
: ${APP_SETUP_WAIT:="5"}

# 1: Define the functions lock and unlock our app containers setup
# processes:
function lock_setup { mkdir -p $APP_TEMP_PATH && touch $APP_SETUP_LOCK; }
function unlock_setup { rm -rf $APP_SETUP_LOCK; }
function wait_setup { echo "Waiting for app setup to finish..."; sleep $APP_SETUP_WAIT; }

# 2: 'Unlock' the setup process if the script exits prematurely:
trap unlock_setup HUP INT QUIT KILL TERM EXIT

# 3: Specify a default command, in case it wasn't issued:
if [ -z "$1" ]; then set -- rails server -p 3000 -b 0.0.0.0 "$@"; fi

# 4: Run the checks only if the app code is going to be executed:
if [[ "$1" = "rails" || "$1" = "sidekiq" ]]
then
  # 5: Wait until the setup 'lock' file no longer exists:
  while [ -f $APP_SETUP_LOCK ]; do wait_setup; done

  # 6: 'Lock' the setup process, to prevent a race condition when the
  # project's app containers will try to install gems and setup the
  # database concurrently:
  lock_setup

  # 7: Check if the database exists, or setup the database if it doesn't,
  # as it is the case when the project runs for the first time.
  #
  # We'll use a custom script `check_db` (inside our app's `bin` folder),
  # instead of running `rails db:version` to avoid loading the entire rails
  # app for this simple check:
  # rails db:version || setup
  # simple check:
  bundle exec checkdb || setup # see `bin/setup` or just add `rails db:setup`

  # 8: 'Unlock' the setup process:
  unlock_setup

  # 9: If the command to execute is 'rails server', then we must remove any
  # pid file present. Suddenly killing and removing app containers might leave
  # this file, and prevent rails from starting-up if present:
  if [[ "$2" = "s" || "$2" = "server" ]]; then rm -rf /usr/src/app/tmp/pids/server.pid; fi
fi

# 10: Execute the given or default command:
exec "$@"

Be sure to add execute permissions to the file by running chmod +x bin/entrypoint-dev after creating this file.

2.3 Create the Docker Compose file:

This is the docker-compose.yml. You may want to do something like this:

version: "2"

volumes:
  postgres-data:
    driver: local
  app-gems:
    driver: local

services:

  postgres:
    image: postgres:9.5.4 # We'll use the official postgres image.
    volumes:
      # Mounts a persistable volume inside the postgres data folder, so we
      # don't lose the created databases when this container is removed.
      - postgres-data:/var/lib/postgresql/data
    environment:
      # The password we'll use to access the databases:
      POSTGRES_PASSWORD: s0m3p455

  web:
    build:
      context: .
      dockerfile: dev.Dockerfile
    # The name our development image will use:
    image: my-namespace/my-app:development
    command: rails server -b 0.0.0.0 -p 3000
    ports:
      # This will bind your port 3000 with the container's port 3000, so we can
      # use 'http://localhost:3000' to see our Rails app:
      - 3000:3000
    links:
      # Makes the postgres service a dependency for our app, and also makes it
      # visible at the 'db' hostname from this container:
      - postgres:db
    entrypoint: /usr/src/app/bin/entrypoint-dev
    volumes:
      # Mounts the app code (".") into the container's "/usr/src/app" folder:
      - .:/usr/src/app
      # Mounts a persistable volume in the installed gems folder, so we can add
      # gems to the app without having to build the development image again:
      - app-gems:/usr/local/bundle
    # Keeps the stdin open, so we can attach to our app container's process and
    # do stuff such as `byebug` or `binding.pry`:
    stdin_open: true
    # Allows us to send signals (CTRL+C, CTRL+P + CTRL+Q) into the container:
    tty: true
    environment:
      # Notice that this is the DB we'll use:
      DATABASE_URL: postgres://postgres:s0m3p455@db:5432/my_app_development
      # We'll use this env variable to make the log output gets directed
      # to Docker:
      RAILS_LOG_TO_STDOUT: "true"

2.3 Edit the database.yml file

We'll need to remove any configuration related to the environment (hostnames, users, passwords) from the config/database.yml file so it can use the data in the DATABASE_URL environment variable instead:

default: &default
  encoding: unicode
  # Schema search path. The server defaults to $user,public
  schema_search_path: partitioning,public
  # For details on connection pooling, see rails configuration guide
  # http://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  # Minimum log levels, in increasing order:
  #   debug5, debug4, debug3, debug2, debug1,
  #   log, notice, warning, error, fatal, and panic
  # Defaults to warning.
  min_messages: log

development:
  <<: *default

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default

# Production database configuration - bigger pool size, lower log level:
production:
  <<: *default
  min_messages: notice

2.4: Edit the development environment logger to output to STDOUT (which Docker Compose will catch)

Edit the config/environments/development.rb file so it includes the if ENV["RAILS_LOG_TO_STDOUT"].present? block:

Rails.application.configure do
  # ....
  if ENV["RAILS_LOG_TO_STDOUT"].present?
    logger           = ActiveSupport::Logger.new(STDOUT)
    logger.formatter = config.log_formatter
    config.logger    = ActiveSupport::TaggedLogging.new(logger)
  end
end

3: Running the app for the very very very first time

We're almost done, but due to the fact that we've not created any migration or table just yet, If we try to start the project now, when the development-entrypoint script hits the setup command, it will fail after creating the database, because there's no database schema to install...

So we'll need to create the database (and also re-create the missing Gemfile.lock file!) and work out our first database migration before we can finally commit some code into Git and share it with our fellow teammates:

# This will run bash again in our development container, this time with the
# database online via Compose:
docker-compose run --rm web bash

# Now that we're inside, we must use bundler to re-create the Gemfile.lock,
# which is missing because it resides in the image we built only... but we
# want it on the code to be able to commit it into Git:
bundle

# Next, let's create the database:
rails db:create

# This is the ideal point where we should create our first scaffold:
rails g scaffold post title:string body:text

# Now we run 'migrate' to create our first schema dump:
rails db:migrate

# Let's exit back into our host:
exit

# We should be now back in our host :)

Now we're ready to share our code. The only thing needed from now on to run the project is the following commands:

# Start the whole project:
docker-compose up -d # Visit http://localhost:3000 to see it running!

# Attach our terminal to the 'web' container (it must be running) - notice that
# we must use the created container name, not the service name:
docker attach myapp_web_1 

# Run one-off commands when the web container is not running:
docker-compose run --rm web bash �# or `rails console`, etc

# Run commands inside a running container:
docker-compose exec web bash

You and your teammates can try these same commands on a freshly cloned copy of the project, and let them see it running automagically in a matter of minutes.

Ref: https://github.com/IcaliaLabs/guides/wiki/Creating-a-new-Rails-application-project-with-Docker

#!/usr/bin/env ruby
# This script is used in the development environment with Docker to check if the
# app database exists, and runs the database setup if it doesn't, as it is the
# case when the project runs for the first time on the development machine.
#
# We are using this custom script instead of running the
# `rake db:version || rake db:setup` commands, as that currently leaves a
# couple of small ruby zombie processes running in the app container:
require "rubygems"
require "rake"
require "bundler"
Bundler.setup(:default)
require "active_record"
exit begin
connection_tries ||= 3
ActiveRecord::Base.establish_connection && ActiveRecord::Migrator.current_version
0
rescue PG::ConnectionBad
unless (connection_tries -= 1).zero?
puts "Retrying DB connection #{connection_tries} more times..."
sleep ENV.fetch("APP_SETUP_WAIT", "5").to_i
retry
end
1
rescue ActiveRecord::NoDatabaseError
2
ensure
ActiveRecord::Base.clear_all_connections!
end
#!/usr/bin/env ruby
require 'pathname'
require 'fileutils'
include FileUtils
# path to your application root.
APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
def system!(*args)
system(*args) || abort("\n== Command #{args} failed ==")
end
chdir APP_ROOT do
# This script is a starting point to setup your application.
# Add necessary setup steps to this file.
puts '== Installing dependencies =='
system! 'gem install bundler --conservative'
system('bundle check') || system!('bundle install')
# puts "\n== Copying sample files =="
# unless File.exist?('config/database.yml')
# cp 'config/database.yml.sample', 'config/database.yml'
# end
puts "\n== Preparing database =="
system! 'bin/rails db:setup'
puts "\n== Removing old logs and tempfiles =="
system! 'bin/rails log:clear tmp:clear'
puts "\n== Restarting application server =="
system! 'bin/rails restart'
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment