Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save mzaidannas/ee6b6b9bdb795816d4c2006a37d45dde to your computer and use it in GitHub Desktop.
Save mzaidannas/ee6b6b9bdb795816d4c2006a37d45dde to your computer and use it in GitHub Desktop.
Simple Docker Setup with Rails+Nginx+Redis+Postgres.md

Typical rails setup with docker+puma+nginx+postgres

Zaid Annas

Devsinc inc. 30/08/2018

Overview

The point is to setup rails production/development environment with docker to cut the initial setup time for deployment as well as local setting up for project.

Goals

  1. Should be feature complete.
  2. Should be easier to setup than regular local setup
  3. Should be easier to deploy than manual rails deployment

Setup

We will be using minimal container setup and will try to keep the container size as small as possible. Create a folder name docker in your project root directory, Now create two more directories inside it app and web.

-app_name
  -app
  -db
  -config
    -database.yml
  ...
  -docker
    -app
      -DockerFile
    -web
      -DockerFile
      -nginx.conf
  -docker-compose.yml

The folder structure we have created is just to keep our files in modular way, you can keep it anywhere you want.

Rails app setup

Put DockerFile of rails app inside app folder.

# Using alpine image for small size
FROM ruby:2.4.2-alpine3.7

# Use virtual build-dependencies tag so we can remove these packages after bundle install
RUN apk update && apk add --update --no-cache --virtual build-dependency build-base ruby-dev mysql-dev postgresql-dev git sqlite-dev

# Set an environment variable where the Rails app is installed to inside of Docker image
ENV RAILS_ROOT /var/www/app_name

# make a new directory where our project will be copied
RUN mkdir -p $RAILS_ROOT

# Set working directory within container
WORKDIR $RAILS_ROOT

# Setting env up
ARG RAILS_ENV
ENV RAILS_ENV=$RAILS_ENV
ENV RAKE_ENV=$RAILS_ENV
ENV RACK_ENV=$RAILS_ENV

# Adding gems
COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock

# development/production differs in bundle install
RUN if [[ "$RAILS_ENV" == "production" ]]; then\
bundle install --jobs 20 --retry 5 --without development test;\
else bundle install --jobs 20 --retry 5; fi

# Remove build dependencies and install runtime dependencies
RUN apk del build-dependency
RUN apk add --update mariadb-client-libs postgresql-client postgresql-libs sqlite-libs nodejs tzdata

# Adding project files
COPY . .

RUN bundle exec rake assets:precompile

EXPOSE 3000

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

These configurations will install essential system requirements, copy your project to docker container, install gems , precompile your assets.

You can use the following puma.config file for this setup

app_dir  =  File.expand_path("../..", __FILE__)
shared_dir  =  "#{app_dir}/tmp"

environment ENV["RACK_ENV"] ||  "development"
threads_count  =  Integer(ENV["MAX_THREADS"] ||  1) # this is should be calculated so (web_concurrency * max_threads * num dynos) PLUS whatever other db threads will be used (by workers for example) is < than allowed heroku pg connections.
threads threads_count, threads_count

preload_app!
rackup DefaultRackup

if  ENV["RACK_ENV"].nil? ||  ENV["RACK_ENV"] ==  "development"  # don't need this for production just local
  port ENV["PORT"] ||  3000
else
  # easier to debug if development is running in single process
  workers Integer(ENV["WEB_CONCURRENCY"] ||  1) # this should be upped in prod as it's using 3 for 1x dyno, 6 for 2x
  # bind "ssl://127.0.0.1:#{ENV['SSL_PORT'] || 3001}?key=#{ENV['SSL_KEY']}&cert=#{ENV['SSL_CERT']}"
  # Deamonize puma server to run in background
  daemonize
  # Set up puma to listen on unix socket location(instead of tcp)
  #bind "unix://#{shared_dir}/sockets/rev1.sock?umask=0111"
  port ENV["PORT"] ||  3000
  # Redirect puma logs(access and error) for this site to shared/logs dir
  stdout_redirect "#{shared_dir}/logs/rev1.stdout.log", "#{shared_dir}/logs/rev1.stderr.log", true
  # Set master PID and state locations
  pidfile "#{shared_dir}/pids/rev1.pid"
  state_path "#{shared_dir}/pids/rev1.state"
  activate_control_app
end

before_fork do
  require  "puma_worker_killer"
  unless  Rails.env.development? ||  Rails.env.test?
    PumaWorkerKiller.enable_rolling_restart(3  *  3600)
  end
  ActiveRecord::Base.connection_pool.disconnect!
end

on_worker_boot do
  # Worker specific setup for Rails 4.1+
  # See: https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#on-worker-boot
  ActiveRecord::Base.establish_connection
end

on_restart do
  Sidekiq.redis.shutdown(&:close)
end

# Allow puma to be restarted by `rails restart` command.
plugin :tmp_restart

Configure reverse proxy, web server

We need a reverse proxy, in our case the Nginx web server, to proxy requests to Puma

DockerFile for nginx

Put DockerFile of nginx inside web folder

# Base image
FROM nginx:mainline-alpine

# Install dependencies
RUN apk update && apk add --update apache2-utils

# Using argument for conditional setup in conf file  
ARG RAILS_ENV
ENV RAILS_ENV $RAILS_ENV

# establish where Nginx should look for files
ENV RAILS_ROOT /var/www/app_name

# Set our working directory inside the image
WORKDIR $RAILS_ROOT

# create log directory
RUN mkdir log

# copy over static assets
COPY public public/

# Copy Nginx config template
COPY docker/web/nginx.conf /tmp/docker.nginx

# substitute variable references in the Nginx config template for real values from the environment
# put the final config in its place
RUN envsubst '$RAILS_ROOT $RAILS_ENV' < /tmp/docker.nginx > /etc/nginx/conf.d/default.conf

EXPOSE 80

# Use the "exec" form of CMD so Nginx shuts down gracefully on SIGTERM (i.e. `docker stop`)
CMD [ "nginx", "-g", "daemon off;" ]

Nginx configuration file

Put nginx.conf inside web folder

upstream rails_app {
	# if ( $RAILS_ENV = "production" ){
		# server unix://$RAILS_ROOT/tmp/sockets/app_name.sock;
	# else
		server app:3000;
	# }
}

  

server {
	# Nginx should listen on port 80 for requests to localhost
	listen 80 default_server;
	listen [::]:80 default_server;
	
	# define your domain
	server_name app_name.com www.app_name.com;
	
	# define the public application root
	root $RAILS_ROOT/public;
	index index.html;
	
	# define where Nginx should write its logs
	access_log $RAILS_ROOT/log/nginx.access.log;
	error_log $RAILS_ROOT/log/nginx.error.log;
	
	# deny requests for files that should never be accessed
	location ~ /\. {
		deny all;
	}
	location ~* ^.+\.(rb|log)$ {
		deny all;
	}

	# serve static (compiled) assets directly if they exist (for rails production)
	location ~ ^/(assets|images|javascripts|stylesheets|swfs|system)/ {
		try_files $uri @rails;
		access_log off;
		gzip_static on;
		
		# to serve pre-gzipped version
		expires max;
		add_header Cache-Control public;
		add_header Last-Modified "";
		add_header ETag "";
		break;
	}

	# send non-static file requests to the app server
	location / {
		try_files $uri @rails;
	}

	location @rails {
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_next_upstream error timeout invalid_header http_502;
		proxy_set_header X-Forwarded-Proto http;
		proxy_set_header Host $http_host;
		proxy_redirect off;
		proxy_pass http://rails_app;
	}
}

Manage all containers with docker-compose

Since our application will be running across multiple containers it would be nice to control them all as one. That is what Docker Compose does for us. To get our app started with Docker Compose create a file docker-compose.yml in the root of your Rails app.

version: '3.7'

# To access data postgres writes within container from host machine
volumes:
  postgres_data: {}

services:
  db:
    image: postgres:11-alpine
    restart: always
    environment:
      POSTGRES_PASSWORD: $DB_PASSWORD
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"

  app:
    build:
      context: .
      dockerfile: ./docker/app/DockerFile
      args:
        RAILS_ENV: $RAILS_ENV
    depends_on:
      - db
      - redis
    # env_file: .docker/.env
    ports:
      - "3000:3000"

  web:
    build:
      context: .
      dockerfile: ./docker/web/DockerFile
      args:
        RAILS_ENV: $RAILS_ENV

    depends_on:
      - app

    ports:
      - 80:80

To resolve environment variables in docker-compose.yml file, you can generate a .env file in the same directory where docker-compose.yml file is saved. docker-compose will automatically get the environment variables from this file. Which in our case is the same file we use for our rails server too and is located on project root path

Containerize your database

Reference docker container named db that we are using to run our Postgresql database. you’ll need to update your database.yml similar to this

default: &default
  adapter: postgresql
  encoding: unicode
  username: postgres
  password: <%= ENV['DB_PASSWORD'] %>
  pool: 5
  host: db 

development:
  <<: *default
  database: app_name_development

At this point you should be able to build all containers with docker-compose build

Once built you can initialize your DB with docker-compose run app rake db:create and then populate it using docker-compose run app rake db:migrate db:seed.

If you would like your container to run the same executable every time, then you should consider using entrypoint in combination with CMD. See ENTRYPOINT

Now we can finally run application with docker-compose up.

To verify that all three containers are up and running execute docker ps

To remove all containers and images you can run these commands

# bash/zsh
docker rm $(docker ps -a -q)
docker rmi $(docker images -q)

# fish
docker rm (docker ps -a -q)
docker rmi (docker images -q)

You can run any command in running container

docker-compose exec container_name command

Or run command in new container

docker-compose run container_name command
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment