Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save x-yuri/d5458aef21018962af2808339e605c2d to your computer and use it in GitHub Desktop.
Save x-yuri/d5458aef21018962af2808339e605c2d to your computer and use it in GitHub Desktop.
#docker #docker-compose #alpine #debian #ruby #pg #puma #nginx #mina

Running a site in production with Docker Compose

TODO

  • pg: use superuser account (create extension)
  • docker-compose.yml: prefer dicts over arrays
  • docker-compose.yml: no explicit network is needed
  • add UID variable
  • .dockerignore
  • docker-compose.yml: environment after env_file
  • .dockerignore shouldn't be too strict
  • prefer CMD over ENTRYPOINT
  • assets don't appear in nginx image

After running a number of projects under docker I've come to a conclusion that producing one big commit is a wrong way to go about it. Actually, I've never considered big commits a viable option, but that's besides the point. The best way, as I see it, is to split changes into a number of commits. With each commit adding a "feature" that might or might not be useful (might be reused) in other projects. That aim can't be achieved fully, but we're going to close in on that.

This gist reflects the idea in a way. It's split into sections that might be needed in some projects, but not in others. Although the document is meant to be read (the first time) from start to finish, since I don't explain things twice.

To be specific I've chosen Debian as the host OS. The goal is to show—and more importantly—explain changes needed to run a typical rails application. As much as I'd like to document any "feature" one might need in one gist, that doesn't seem reasonable. At least not at the moment. But nevertheless I cover two options for the guest OS: 1) Alpine Linux, 2) Debian.

Also, I do not favor copy-pasting configs or code in general. I believe that any line must be there for a reason. But to be able to quickly inspect the solution I've added a section with the resulting changes.

Also there's a repository, or more precisely two of its branches (one for Alpine Linux, the other for Debian) where you can find the corresponding commits.

The target setup is as follows:

  *--------------------------------------------------*
  | server            *----------------------------* |
  |                   | app 1                      | |
  | *-------*         | *-------*      *-----*     | |
  | |       |         | |       |      |     |     | |
 -> | nginx | <--*----> | nginx | <--> | app | ... | |
  | |       |    |    | |       |      |     |     | |
  | *-------*    |    | *-------*      *-----*     | |
  |              |    *----------------------------* |
  |              |                                   |
  |              |    *----------------------------* |
  |              |    | app 2                      | |
  |              |    | *-------*      *-----*     | |
  |              |    | |       |      |     |     | |
  |              *----> | nginx | <--> | app | ... | |
  |              |    | |       |      |     |     | |
  |              .    | *-------*      *-----*     | |
  |              .    *----------------------------* |
  |              .                                   |
  *--------------------------------------------------*

The publicly available nginx (nginx-proxy actually) is going to forward requests to app nginx's. Which in their turn will either serve a static file, or forward the request it to the app.

Also, we're going to use jrcs/letsencrypt-nginx-proxy-companion to obtain ssl certificates and feed them to nginx-proxy, and mina for deploy.

Table of contents

Development environment

Basic settings

Dockerfile.development:

FROM ruby:2-alpine

ENV BUNDLE_PATH vendor/bundle

RUN apk add --no-cache build-base tzdata \
        nodejs-current yarn \
    && gem install bundler
WORKDIR /app

CMD ["docker/entrypoint-development.sh"]

By default, gems are installed to /usr/local/bundle, BUNDLE_PATH changes the location. This way we can access them from the host more easily.

--no-cache makes apk (Alpine Linux's package manager) not cache the index. That allows to reduce the resulting image size to some extent. build-base to Alpine Linux is what build-essential to Debian. It contains tools needed for building from source (e.g. gems with native extensions).

tzdata provides the IANA Time Zone Database. rails needs it to perform date/time calculations. It uses tzinfo gem to obtain information about timezones. And tzinfo can work with 2 data sources: 1) system database (provided by tzdata package on Alpine Linux systems, not installed by default in the docker image), 2) database provided by tzinfo-data gem (packaged as Ruby modules). That's the reason why rails adds tzinfo-data gem to the generated Gemfile for Windows hosts. Because the latter has no zoneinfo files.

WORKDIR (in addition to changing) creates the directory if it doesn't exist.

As for the CMD line, let me provide some background here. In the beginning was the sh -c entrypoint. And it was good :) But people wanted more (they always do), and so ENTRYPOINT came into being. But I prefer CMD because it's easier to override. --entrypoint is 12 extra characters plus space, and all that must come before the image name. And there's rarely a reason to have something other than sh -c as an entrypoint, if you think about it. So rarely, that I can't think of even a single case.

docker-compose-development.yml:

version: '3'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.development
    networks:
      - app
    ports:
      - 127.0.0.1:${APP_PORT-3000}:3000
    volumes:
      - .:/app

networks:
  app:

If you don't specify a network, the containers get attached to the default bridge network. The latter is considered a legacy, and is not recommended for production use. So, it's generally best to always specify some network. More on it here.

127.0.0.1: makes it listen only to localhost, not to all the interfaces. You generally don't want to have your development instance publicly available.

By default, the app service is going to bind to port 3000 on the host. But you can override that with APP_PORT variable, either by APP_PORT=... docker-compose up, or by putting APP_PORT into the .env file. And I mean it, the .env file, not .env.development or something. There are two places docker-compose consults for variables: process environment, and the .env file.

The thing I don't like is that there are two "sources of truth" here. Port 3000 is specified in the docker-compose.yml file, and implicitly in the entrypoint. I could add, say, APP_PORT_INTERNAL variable to the .env file, add .env file to the app service with the env_file directive, and use this variable in those two places. But that means the .env file is to be added to the repository. And I'd like to leave it for developer (local) overrides. So I decided to live with that (break the Single Source of Truth principle).

The current directory is bind-mounted to the /app dir in the container. This way changes on the host automatically propagate into the container, and vice versa.

docker/entrypoint-development.sh:

#!/bin/sh
set -eu
exec bin/rails server --binding 0.0.0.0

-e - exit if a command fails,
-u - treat an unset variable as an error.

By default, bin/rails server in development environment binds only to localhost. We want it to listen to the external interface to be able to access it from the host.

README.md:

# Running locally

```
docker-compose pull
docker-compose build
docker-compose run app bundle install
docker-compose run app yarn install
docker-compose up
```

Debian

Dockerfile.development for Debian:

FROM ruby:2

ENV BUNDLE_PATH vendor/bundle

RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - \
    && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" \
        | tee /etc/apt/sources.list.d/yarn.list \
    && apt-get update \
    && apt-get install -y nodejs yarn \
    && gem install bundler
WORKDIR /app

CMD ["docker/entrypoint-development.sh"]

-y - assume "yes" as the answer to all prompts.

Make web-console work

diff --git a/config/environments/development.rb b/config/environments/development.rb
index 66df51f..86789ab 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -1,3 +1,6 @@
+require 'socket'
+require 'ipaddr'
+
 Rails.application.configure do
   # Settings specified here will take precedence over those in config/application.rb.
 
@@ -59,4 +62,8 @@ Rails.application.configure do
   # Use an evented file watcher to asynchronously detect changes in source code,
   # routes, locales, etc. This feature depends on the listen gem.
   config.file_watcher = ActiveSupport::EventedFileUpdateChecker
+
+  config.web_console.permissions = Socket.getifaddrs
+    .select { |ifa| ifa.addr.ipv4_private? }
+    .map { |ifa| IPAddr.new(ifa.addr.ip_address + '/' + ifa.netmask.ip_address) }
 end

By default, web-console is available only to localhost. When running rails in a container, web-console is available only to the container's localhost. permissions setting makes it available to all the interfaces (localhost is always permitted). Socket.getifaddrs returns a list of interface addresses. .select leaves only the private addresses of the AF_INET family (the ones we know and love :)). .map turns them into a list of IPAddr instances.

Add db service

.env.development:

PG_HOST=db
PG_USER=postgres
PG_DB=postgres

Locally we don't care much about who can access our database. As such we can not specify POSTGRES_PASSWORD. In this case pg trusts all remote connections (no password is needed).

Additionally, we can avoid specifying POSTGRES_USER, POSTGRES_DATABASE. postgres user and database are created by default.

diff --git a/Dockerfile.development b/Dockerfile.development
index 140c614..56864e9 100644
--- a/Dockerfile.development
+++ b/Dockerfile.development
@@ -4,6 +4,8 @@ ENV BUNDLE_PATH vendor/bundle
 
 RUN apk add --no-cache build-base tzdata \
         nodejs-current yarn \
+        postgresql-dev \
+        wait4ports \
     && gem install bundler
 WORKDIR /app
 

postgresql-dev package contains header files needed to build the pg gem, and it depends on libpq package which contains the library needed to interact with postgresql (and to use the gem).

diff --git a/README.md b/README.md
index e83ee54..f9b6d94 100644
--- a/README.md
+++ b/README.md
@@ -5,5 +5,6 @@ docker-compose pull
 docker-compose build
 docker-compose run app bundle install
 docker-compose run app yarn install
+docker-compose run app bin/rails db:migrate
 docker-compose up

```diff
diff --git a/config/database.yml b/config/database.yml
index bc3cbf1..a927495 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -1,17 +1,17 @@
 default: &default
   adapter: postgresql
+  host: '<%= ENV["PG_HOST"] %>'
+  username: '<%= ENV["PG_USER"] %>'
   pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
 
 development:
   <<: *default
-  database: APP_NAME_development
+  database: '<%= ENV["PG_DB"] || "APP_NAME_development" %>'
 
 test:
   <<: *default
-  database: APP_NAME_test
+  database: '<%= ENV["PG_DB"] || "APP_NAME_test" %>'
 
 production:
   <<: *default
-  database: APP_NAME_production
-  username: APP_NAME
-  password: <%= ENV['RA1_DATABASE_PASSWORD'] %>
+  database: '<%= ENV["PG_DB"] || "APP_NAME_production" %>'
diff --git a/docker-compose-development.yml b/docker-compose-development.yml
index f9a98c9..acfc9e8 100644
--- a/docker-compose-development.yml
+++ b/docker-compose-development.yml
@@ -5,12 +5,30 @@ services:
     build:
       context: .
       dockerfile: Dockerfile.development
+    env_file:
+      - .env.development
     networks:
       - app
     ports:
       - 127.0.0.1:${APP_PORT-3000}:3000
     volumes:
       - .:/app
+    depends_on:
+      - db
+
+  db:
+    image: postgres:12-alpine
+    env_file:
+      - .env.development
+    networks:
+      - app
+    ports:
+      - 127.0.0.1:${PG_PORT-5432}:5432
+    volumes:
+      - db:/var/lib/postgresql/data
 
 networks:
   app:
+
+volumes:
+  db:
diff --git a/docker/entrypoint-development.sh b/docker/entrypoint-development.sh
index aaa2bac..e02c25f 100755
--- a/docker/entrypoint-development.sh
+++ b/docker/entrypoint-development.sh
@@ -1,3 +1,4 @@
 #!/bin/sh
 set -eu
+wait4ports -s 10 tcp://"$PG_HOST:5432"
 exec bin/rails server --binding 0.0.0.0

Debian

diff --git a/Dockerfile.development-debian b/Dockerfile.development-debian
index 02fe964..a392af8 100644
--- a/Dockerfile.development-debian
+++ b/Dockerfile.development-debian
@@ -7,7 +7,7 @@ RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - \
     && echo "deb https://dl.yarnpkg.com/debian/ stable main" \
         | tee /etc/apt/sources.list.d/yarn.list \
     && apt-get update \
-    && apt-get install -y nodejs yarn \
+    && apt-get install -y nodejs yarn wait-for-it \
     && gem install bundler
 WORKDIR /app
 

docker-compose-development.yml:

version: '3'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.development
    env_file:
      - .env.development
    networks:
      - app
    ports:
      - 127.0.0.1:${APP_PORT-3000}:3000
    volumes:
      - .:/app
    depends_on:
      - db

  db:
    image: postgres:12
    env_file:
      - .env.development
    networks:
      - app
    ports:
      - 127.0.0.1:${PG_PORT-5432}:5432
    volumes:
      - db:/var/lib/postgresql/data

networks:
  app:

volumes:
  db:

docker/entrypoint-development.sh:

#!/bin/sh
set -eu
wait-for-it "$PG_HOST:5432"
exec bin/rails server --binding 0.0.0.0

Production instance

Basic settings

.dockerignore:

/log
/tmp
/vendor

You generally don't want the contents of these directories to appear in the resulting image.

.env.production:

RAILS_ENV=production
RAILS_LOG_TO_STDOUT=1

rails is made to log to stdout to be able to see the logs with docker logs.

Dockerfile.production:

FROM ruby:2-alpine

RUN apk add --no-cache build-base tzdata \
        nodejs-current yarn \
    && gem install bundler
WORKDIR /app
COPY Gemfile Gemfile.lock package.json yarn.lock ./
RUN bundle install --without development test \
    && NODE_ENV=production yarn install
COPY . .
RUN SECRET_KEY_BASE=`bin/rails secret` RAILS_ENV=production \
        bin/rails assets:precompile

FROM ruby:2-alpine

RUN apk add --no-cache tzdata \
    && gem install bundler
WORKDIR /app
COPY . .
COPY --from=0 /usr/local/bundle /usr/local/bundle
COPY --from=0 /app/public/assets public/assets
COPY --from=0 /app/public/packs public/packs

CMD ["docker/entrypoint-production.sh"]

bundler's (Gemfile*) and yarn's (package.json, yarn.lock) files are copied first for changes to other files to not lead to installing gems/packages. This way if any of these 4 files changes, the installing gems/packages step is executed. If not, the corresponding layer is taken from cache.

docker-compose-production.yml:

version: '3'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.production
    env_file:
      - .env.production
      - .env.production.secret
    environment:
      - "VIRTUAL_HOST=DOMAIN"  # for jwilder/nginx-proxy
      - "LETSENCRYPT_HOST=DOMAIN"  # for jrcs/letsencrypt-nginx-proxy-companion
    expose:
      - 3000  # for jwilder/nginx-proxy
    networks:
      - nginx-proxy
    restart: always

networks:
  nginx-proxy:
    external: true

.env.production.secret is for sensitive information, the one you don't want to put in the repository.

We're going to use nginx-proxy and letsencrypt-nginx-proxy-companion to make sites available to the outside world. nginx-proxy will be the only publicly available nginx instance. To make it proxy requests to your app, you've got to tell it the desired domain (VIRTUAL_HOST environment variable), attach it to nginx-proxy's network (nginx-proxy network), tell it which port the app is running on (by exposing it), and tell letsencrypt-nginx-proxy-companion the domain to obtain a certificate for (LETSENCRYPT_HOST environment variable).

Apart from restarting containers when they fail, restart: always serves one more nonobvious role. When you restart the server the containers wouldn't automatically start, unless they had restart: always when they were created.

docker/entrypoint-production.sh:

#!/bin/sh
set -eu
exec bin/rails server

Debian

Dockerfile.production (debian):

FROM ruby:2

RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - \
    && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" \
        | tee /etc/apt/sources.list.d/yarn.list \
    && apt-get update \
    && apt-get install -y nodejs yarn \
    && gem install bundler
WORKDIR /app
COPY Gemfile Gemfile.lock package.json yarn.lock ./
RUN bundle install --without development test \
    && NODE_ENV=production yarn install
COPY . .
RUN SECRET_KEY_BASE=`bin/rails secret` RAILS_ENV=production \
        bin/rails assets:precompile

FROM ruby:2

RUN gem install bundler
WORKDIR /app
COPY . .
COPY --from=0 /usr/local/bundle /usr/local/bundle
COPY --from=0 /app/public/assets public/assets
COPY --from=0 /app/public/packs public/packs

CMD ["docker/entrypoint-production.sh"]

User uploads

diff --git a/.dockerignore b/.dockerignore
index 3f1281f..3deb941 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,4 @@
 /log
+/public/uploads
 /tmp
 /vendor
diff --git a/docker-compose-production.yml b/docker-compose-production.yml
index 08dcd1e..45a08aa 100644
--- a/docker-compose-production.yml
+++ b/docker-compose-production.yml
@@ -15,8 +15,13 @@ services:
       - 3000  # for jwilder/nginx-proxy
     networks:
       - nginx-proxy
+    volumes:
+      - uploads:/app/public/uploads
     restart: always
 
 networks:
   nginx-proxy:
     external: true
+
+volumes:
+  uploads:

Add db service

diff --git a/.env.production b/.env.production
index 19f0af4..2615690 100644
--- a/.env.production
+++ b/.env.production
@@ -1,2 +1,6 @@
 RAILS_ENV=production
 RAILS_LOG_TO_STDOUT=1
+
+PG_HOST=db
+PG_USER=APP_NAME
+PG_DB=APP_NAME_production

APP_NAME - your app name.

diff --git a/Dockerfile.production b/Dockerfile.production
index 7a61ffd..0e795e6 100644
--- a/Dockerfile.production
+++ b/Dockerfile.production
@@ -2,6 +2,7 @@ FROM ruby:2-alpine
 
 RUN apk add --no-cache build-base tzdata \
         nodejs-current yarn \
+        postgresql-dev \
     && gem install bundler
 WORKDIR /app
 COPY Gemfile Gemfile.lock package.json yarn.lock ./
@@ -13,7 +14,8 @@ RUN SECRET_KEY_BASE=`bin/rails secret` RAILS_ENV=production \
 
 FROM ruby:2-alpine
 
-RUN apk add --no-cache tzdata \
+# pg <- libpq
+RUN apk add --no-cache tzdata libpq wait4ports \
     && gem install bundler
 WORKDIR /app
 COPY . .

libpq library allows ruby to interact with postresql, and is used by the pg gem.

diff --git a/docker-compose-production.yml b/docker-compose-production.yml
index 45a08aa..01da477 100644
--- a/docker-compose-production.yml
+++ b/docker-compose-production.yml
@@ -18,6 +18,19 @@ services:
     volumes:
       - uploads:/app/public/uploads
     restart: always
+    depends_on:
+      - db
+
+  db:
+    image: postgres:12-alpine
+    env_file:
+      - .env.production
+    networks:
+      - app
+    volumes:
+      - db:/var/lib/postgresql/data
+      - ./docker/init-pg.sh:/docker-entrypoint-initdb.d/init-pg.sh
+    restart: always
 
 networks:
   nginx-proxy:
@@ -25,3 +38,4 @@ networks:
 
 volumes:
   uploads:
+  db:

init-pg.sh is executed on the first run. It create the user and the database.

diff --git a/docker/entrypoint-production.sh b/docker/entrypoint-production.sh
index a51e8ab..a56cfc8 100755
--- a/docker/entrypoint-production.sh
+++ b/docker/entrypoint-production.sh
@@ -1,3 +1,5 @@
 #!/bin/sh
 set -eu
+wait4ports -s 10 tcp://"$PG_HOST:5432"
+bin/rails db:migrate
 exec bin/rails server

docker/init-pg.sh:

psql -v ON_ERROR_STOP=1 \
    -v PG_USER="$PG_USER" \
    -v PG_DB="$PG_DB" \
<<-EOSQL
    CREATE USER :PG_USER;
    CREATE DATABASE :PG_DB;
    GRANT ALL PRIVILEGES ON DATABASE :PG_DB TO :PG_USER;
EOSQL

Debian

diff --git a/Dockerfile.production b/Dockerfile.production
index 85c1292..26381ca 100644
--- a/Dockerfile.production
+++ b/Dockerfile.production
@@ -17,7 +17,10 @@ RUN SECRET_KEY_BASE=`bin/rails secret` RAILS_ENV=production \
 
 FROM ruby:2
 
-RUN gem install bundler
+RUN apt-get update \
+    && apt-get install -y wait-for-it \
+    && rm -rf /var/lib/apt/lists/* \
+    && gem install bundler
 WORKDIR /app
 COPY . .
 COPY --from=0 /usr/local/bundle /usr/local/bundle

Emptying /var/lib/apt/lists directory is recommended to reduce the image size.

docker-compose-production.yml:

version: '3'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.production
    env_file:
      - .env.production
      - .env.production.secret
    environment:
      - "VIRTUAL_HOST=DOMAIN"  # for jwilder/nginx-proxy
      - "LETSENCRYPT_HOST=DOMAIN"  # for jrcs/letsencrypt-nginx-proxy-companion
    expose:
      - 3000  # for jwilder/nginx-proxy
    networks:
      - nginx-proxy
    volumes:
      - uploads:/app/public/uploads
    restart: always
    depends_on:
      - db

  db:
    image: postgres:12
    env_file:
      - .env.production
    networks:
      - app
    volumes:
      - db:/var/lib/postgresql/data
      - ./docker/init-pg.sh:/docker-entrypoint-initdb.d/init-pg.sh
    restart: always

networks:
  nginx-proxy:
    external: true

volumes:
  uploads:
  db:

entrypoint-production.sh:

#!/bin/sh
set -eu
wait-for-it "$PG_HOST:5432"
bin/rails db:migrate
exec bin/rails server

Integrate puma

diff --git a/.env.production b/.env.production
index 2615690..4f468c7 100644
--- a/.env.production
+++ b/.env.production
@@ -1,5 +1,8 @@
 RAILS_ENV=production
 RAILS_LOG_TO_STDOUT=1
+RAILS_MIN_THREADS=5
+RAILS_MAX_THREADS=5
+PUMA_N_WORKERS=0
 
 PG_HOST=db
 PG_USER=APP_NAME

config/puma.rb:

max_threads = ENV.fetch('RAILS_MAX_THREADS') { 5 }
min_threads = ENV.fetch('RAILS_MIN_THREADS') { max_threads }
threads min_threads, max_threads

n_workers = ENV.fetch('PUMA_N_WORKERS') { 0 }
workers n_workers
if n_workers.to_i > 0
  preload_app!
end

plugin :tmp_restart

Add nginx service

diff --git a/.env.production b/.env.production
index 4f468c7..2b28622 100644
--- a/.env.production
+++ b/.env.production
@@ -1,3 +1,4 @@
+APP_PORT=3000
 RAILS_ENV=production
 RAILS_LOG_TO_STDOUT=1
 RAILS_MIN_THREADS=5

We're going to need the APP_PORT variable in both nginx and app entrypoints.

Dockerfile.nginx:

FROM ruby:2-alpine

RUN apk add --no-cache build-base tzdata \
        nodejs-current yarn \
        postgresql-dev \
    && gem install bundler
WORKDIR /app
COPY Gemfile Gemfile.lock package.json yarn.lock ./
RUN bundle install --without development test \
    && NODE_ENV=production yarn install
COPY . .
RUN SECRET_KEY_BASE=`bin/rails secret` RAILS_ENV=production \
        bin/rails assets:precompile

FROM nginx:1.16-alpine

RUN apk add --no-cache wait4ports
COPY --from=0 /app/public /docroot
COPY docker/nginx-vhost.tmpl docker/entrypoint-nginx.sh /

CMD ["/entrypoint-nginx.sh"]

We're duplicating assets-building code here to simplify building docker images. This way we can build the images with just docker-compose build. Otherwise, we'd have to build the app image first, then copy data from the image to the host, then build nginx image.

diff --git a/docker-compose-production.yml b/docker-compose-production.yml
index 01da477..7a519e4 100644
--- a/docker-compose-production.yml
+++ b/docker-compose-production.yml
@@ -1,20 +1,35 @@
 version: '3'
 
 services:
-  app:
+  nginx:
     build:
       context: .
-      dockerfile: Dockerfile.production
+      dockerfile: Dockerfile.nginx
     env_file:
       - .env.production
-      - .env.production.secret
     environment:
       - "VIRTUAL_HOST=DOMAIN"  # for jwilder/nginx-proxy
       - "LETSENCRYPT_HOST=DOMAIN"  # for jrcs/letsencrypt-nginx-proxy-companion
     expose:
-      - 3000  # for jwilder/nginx-proxy
+      - 80  # for jwilder/nginx-proxy
     networks:
       - nginx-proxy
+      - app
     volumes:
-      - uploads:/app/public/uploads
+      - uploads:/docroot/uploads
     restart: always
     depends_on:
-      - db
+      - app
+
+  app:
+    build:
+      context: .
+      dockerfile: Dockerfile.production
+    env_file:
+      - .env.production
+      - .env.production.secret
+    networks:
+      - app
+    volumes:
+      - uploads:/app/public/uploads
+    restart: always
+    depends_on:
+      - db

@@ -35,6 +50,7 @@ services:
 networks:
   nginx-proxy:
     external: true
+  app:
 
 volumes:
   uploads:

docker/entrypoint-nginx.sh:

#!/bin/sh
set -eu
envsubst '$$APP_PORT' \
    < /nginx-vhost.tmpl \
    > /etc/nginx/conf.d/default.conf
wait4ports -s 10 tcp://app:"$APP_PORT"
exec nginx -g 'daemon off;'

envsubst generates nginx config from nginx-vhost.tmpl substituting the APP_PORT variable.

diff --git a/docker/entrypoint-production.sh b/docker/entrypoint-production.sh
index a56cfc8..e499a64 100755
--- a/docker/entrypoint-production.sh
+++ b/docker/entrypoint-production.sh
@@ -2,4 +2,4 @@
 set -eu
 wait4ports -s 10 tcp://"$PG_HOST:5432"
 bin/rails db:migrate
-exec bin/rails server
+exec bin/rails server -p "$APP_PORT"

docker/nginx-vhost.tmpl:

server {
    root  /docroot;
    location / {
        try_files  $uri  @app;
    }
    location @app {
        proxy_pass  "http://app:$APP_PORT";
        proxy_set_header  Host  $http_host;
        proxy_set_header  X-Forwarded-For  "$http_x_forwarded_for, $realip_remote_addr";
    }

    client_max_body_size  50m;

    set_real_ip_from   127.0.0.0/8;
    set_real_ip_from   10.0.0.0/8;
    set_real_ip_from   172.16.0.0/12;
    set_real_ip_from   192.168.0.0/16;
    real_ip_header     X-Forwarded-For;
    real_ip_recursive  on;

    # https://github.com/h5bp/server-configs-nginx/blob/3.1.0/h5bp/media_types/character_encodings.conf
    charset  utf-8;
    charset_types
        text/css
        text/plain
        text/vnd.wap.wml
        text/javascript
        text/markdown
        text/calendar
        text/x-component
        text/vcard
        text/cache-manifest
        text/vtt
        application/json
        application/manifest+json;

    # https://github.com/h5bp/server-configs-nginx/blob/3.1.0/h5bp/web_performance/compression.conf
    gzip  on;
    gzip_comp_level  5;
    gzip_min_length  256;
    gzip_proxied  any;
    gzip_vary  on;
    gzip_types
        application/atom+xml
        application/geo+json
        application/javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rdf+xml
        application/rss+xml
        application/vnd.ms-fontobject
        application/wasm
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/otf
        image/bmp
        image/svg+xml
        text/cache-manifest
        text/calendar
        text/css
        text/javascript
        text/markdown
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;

    gzip_static  on;
}

One can't use proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for here, because the realip module changes the $remote_addr variable. And $proxy_add_x_forwarded_for is X-Forwarded-For header plus $remote_addr.

The realip module makes the client ip address appear in the access log (changes $remote_addr) in place of the nginx-proxy's one.

charset makes nginx add ; charset=utf-8 to the Content-Type header for text/html files, charset_types specifies additional types of files to add charset to.

gzip_proxied any - compress any response, gzip_vary on - make the response cached properly (tell downstream user agents that the response depends on the Accept-Encoding header), gzip_types - which filetypes to compress.

gzip_static on makes nginx check if there's a precompressed file ($request_filename + .gz). If so, the precompressed file is served (avoids processing where possible).

Debian

Dockerfile.nginx:

FROM ruby:2

RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - \
    && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" \
        | tee /etc/apt/sources.list.d/yarn.list \
    && apt-get update \
    && apt-get install -y nodejs yarn \
    && gem install bundler
WORKDIR /app
COPY Gemfile Gemfile.lock package.json yarn.lock ./
RUN bundle install --without development test \
    && NODE_ENV=production yarn install
COPY . .
RUN SECRET_KEY_BASE=`bin/rails secret` RAILS_ENV=production \
        bin/rails assets:precompile

FROM nginx:1.16

RUN apt-get update \
    && apt-get install -y wait-for-it \
    && rm -rf /var/lib/apt/lists/*
COPY --from=0 /app/public /docroot
COPY docker/nginx-vhost.tmpl docker/entrypoint-nginx.sh /

CMD ["/entrypoint-nginx.sh"]
diff --git a/docker-compose-production.yml b/docker-compose-production.yml
index 81597e2..ba11e5b 100644
--- a/docker-compose-production.yml
+++ b/docker-compose-production.yml
@@ -1,20 +1,35 @@
 version: '3'
 
 services:
-  app:
+  nginx:
     build:
       context: .
-      dockerfile: Dockerfile.production
+      dockerfile: Dockerfile.nginx
     env_file:
       - .env.production
-      - .env.production.secret
     environment:
       - "VIRTUAL_HOST=DOMAIN"  # for jwilder/nginx-proxy
       - "LETSENCRYPT_HOST=DOMAIN"  # for jrcs/letsencrypt-nginx-proxy-companion
     expose:
-      - 3000  # for jwilder/nginx-proxy
+      - 80  # for jwilder/nginx-proxy
     networks:
       - nginx-proxy
+      - app
     volumes:
-      - uploads:/app/public/uploads
+      - uploads:/docroot/uploads
     restart: always
     depends_on:
-      - db
+      - app
+
+  app:
+    build:
+      context: .
+      dockerfile: Dockerfile.production
+    env_file:
+      - .env.production
+      - .env.production.secret
+    networks:
+      - app
+    volumes:
+      - uploads:/app/public/uploads
+    restart: always
+    depends_on:
+      - db

@@ -35,6 +50,7 @@ services:
 networks:
   nginx-proxy:
     external: true
+  app:
 
 volumes:
   uploads:

docker/entrypoint-nginx.sh:

#!/bin/sh
set -eu
envsubst '$$APP_PORT' \
    < /nginx-vhost.tmpl \
    > /etc/nginx/conf.d/default.conf
wait-for-it app:"$APP_PORT"
exec nginx -g 'daemon off;'

Add deploy

diff --git a/Gemfile b/Gemfile
index 3afc875..d6b0e1c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -29,6 +29,7 @@ gem 'bootsnap', '>= 1.4.2', require: false
 group :development, :test do
   # Call 'byebug' anywhere in the code to stop execution and get a debugger console
   gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
+  gem 'mina'
 end
 
 group :development do

config/deploy.rb:

require 'mina/git'
require 'mina/deploy'

set :domain, 'DOMAIN'
set :user, 'USER'
set :deploy_to, '/home/USER/app'
set :repository, 'REPO_URL'
set :branch, 'BRANCH'
set :shared_files, fetch(:shared_files, []).push('.env.production.secret')

task :deploy do
  deploy do
    invoke :'git:clone'
    invoke :'deploy:link_shared_paths'
    invoke :'deploy:cleanup'
    on :launch do
      docker_compose = 'docker-compose -p APP_NAME -f docker-compose-production.yml'
      command "#{docker_compose} pull"
      command "#{docker_compose} build"
      command "#{docker_compose} up -d"
    end
  end
end

DOMAIN, USER, REPO_URL, BRANCH, APP_NAME - are placeholders for real values.

-p APP_NAME gives the docker-compose project a name. Or else it would use the directory name (current). And to control the project you've got to specify both -p and -f, e.g.:

$ docker-compose -p APP_NAME -f docker-compose-production.yml` ps

On the server

Under root

  • Install docker

    # curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add -
    # echo deb https://download.docker.com/linux/debian $(lsb_release -cs) stable \
        > /etc/apt/sources.list.d/docker.list
    # apt update
    # apt install docker-ce
    # systemctl enable --now docker
    

    where -f - fail silently on server errors,
    -sS - don't show progress, but display errors,
    -L - follow redirects,
    -c - display codename,
    -s - use short format,
    --now - start the service as well.

    More on it here.

  • Install docker-compose:

    # curl -L "https://github.com/docker/compose/releases/download/1.24.1/docker-compose-$(uname -s)-$(uname -m)" \
        -o /usr/local/bin/docker-compose
    # chmod +x /usr/local/bin/docker-compose
    

    where -s - print kernel name (e.g. Linux),
    -m - print architecture (e.g. x86_64),
    -o - save response to a file.

    More on it here.

  • Start nginx-proxy with letsencrypt-nginx-proxy-companion:

    ~/nginx-proxy/docker-compose.yml:

    version: '3.5'
    
    services:
      nginx-proxy:
        image: jwilder/nginx-proxy:alpine
        networks:
          - nginx-proxy
        ports:
          - 80:80
          - 443:443
        volumes:
          - ./certs:/etc/nginx/certs
          - vhost.d:/etc/nginx/vhost.d
          - html:/usr/share/nginx/html
          - /var/run/docker.sock:/tmp/docker.sock:ro
        labels:
          com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: ''
        restart: always
    
      letsencrypt:
        image: jrcs/letsencrypt-nginx-proxy-companion
        environment:
          - "DEFAULT_EMAIL=MAILBOX@SOMETHING.COM"
        volumes:
          - ./certs:/etc/nginx/certs
          - vhost.d:/etc/nginx/vhost.d
          - html:/usr/share/nginx/html
          - /var/run/docker.sock:/var/run/docker.sock:ro
        depends_on:
          - nginx-proxy
        restart: always
    
    networks:
      nginx-proxy:
        name: nginx-proxy
    
    volumes:
      vhost.d:
      html:
    # cd nginx-proxy && docker-compose up -d
    

    nginx-proxy is nginx + docker-gen. The latter monitors the containers' state, changing the nginx config when containers get created/destroyed, and reloading nginx (makes nginx proxy requests to the corresponding containers).

    letsencrypt-nginx-proxy-companion obtains the certificates. The com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy label is used to tell letsencrypt-nginx-proxy-companion which is the nginx-proxy container. A number of volumes are used to pass information from letsencrypt-nginx-proxy-companion to nginx-proxy, namely, certificates (/etc/nginx/certs), virtual host settings concerning http-01 challanges (/etc/nginx/vhost.d, location ^~ /.well-known/acme-challenge/ { ... }), and the http-01 challenges themselves (/usr/share/nginx/html).

    DEFAULT_EMAIL is the mailbox that is going to be associated with the Let's Encrypt account that is going to be created to obtain the certificates. This mailbox (contact address) will be used to send notifications about certificates that are about to expire (in case you messed up). The account is created once per letsencrypt-nginx-proxy-companion instance (supposedly the only one on the host), and hence DEFAULT_EMAIL is used only the first time a certificate is obtained. One can specify the contact address per project by adding LETSENCRYPT_HOST variable to the containers, but generally that makes no sense, since only the variable of the first container that needs a certificate is going to be used. There's a way to make it create a new account every time it obtains a certificate, but you might run into account rate limits (10 accounts per IP address per 3 hours at the time of writing), as such that is generally to be avoided.

    Also both services are given access to the docker socket. That lets them make Docker API requests to the docker (running on the host), like:

    $ curl -sS --unix-socket /var/run/docker.sock \
        http://localhost/containers/json |& jq -C | less
    

    where --unix-socket - a socket to connect to.

    jq pretty-prints the resulting json, -C makes it use colors even if outputting to a non-terminal (pipe).

    You can find Docker API description here.

  • Add an app user

    # useradd -ms /bin/bash -G docker myapp
    

    where -m - create the home dir,
    -s - specify the shell,
    -G - add the user to the docker group to be able to start/stop containers, and generally operate docker.

Under the app account

  • Generate an ssh keypair

    $ ssh-keygen -N '' -f ~/.ssh/id_rsa
    

    where -N '' - add no passphrase to the key,
    -f - where to put the the latter.

    With these two switches it doesn't ask questions.

  • Add the public key to GitHub (Deploy keys)

  • Put RAILS_MASTER_KEY=... into ~/app/shared/.env.production.secret, then:

    chmod 0600 ~/app/shared/.env.production.secret
    

Deploy

$ bundle exec mina setup   # only the first time (once)
$ bundle exec mina deploy

Consider adding the -v flag for verbose output.

The resulting changes

TODO

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment