TODO
- pg: use superuser account (create extension)
docker-compose.yml: prefer dicts over arraysdocker-compose.yml: no explicit network is needed- add
UIDvariable .dockerignoredocker-compose.yml:environmentafterenv_file.dockerignoreshouldn't be too strict- prefer
CMDoverENTRYPOINT - assets don't appear in
nginximage
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.
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
```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.
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) }
endBy 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.
.env.development:
PG_HOST=db
PG_USER=postgres
PG_DB=postgresLocally 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.0diff --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.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=1rails 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 serverDockerfile.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"]
diff --git a/.dockerignore b/.dockerignore
index 3f1281f..3deb941 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,4 @@
/log
+/public/uploads
/tmp
/vendordiff --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:
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_productionAPP_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 serverdocker/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;
EOSQLdiff --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/bundleEmptying /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 serverdiff --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_NAMEconfig/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_restartdiff --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=5We'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).
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;'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 doconfig/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
endDOMAIN, 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
-
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 dockerwhere
-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-composewhere
-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-proxywithletsencrypt-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: - "[email protected]" 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 -dnginx-proxyisnginx+docker-gen. The latter monitors the containers' state, changing thenginxconfig when containers get created/destroyed, and reloadingnginx(makesnginxproxy requests to the corresponding containers).letsencrypt-nginx-proxy-companionobtains the certificates. Thecom.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxylabel is used to tellletsencrypt-nginx-proxy-companionwhich is thenginx-proxycontainer. A number of volumes are used to pass information fromletsencrypt-nginx-proxy-companiontonginx-proxy, namely, certificates (/etc/nginx/certs), virtual host settings concerninghttp-01challanges (/etc/nginx/vhost.d,location ^~ /.well-known/acme-challenge/ { ... }), and thehttp-01challenges themselves (/usr/share/nginx/html).DEFAULT_EMAILis 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 perletsencrypt-nginx-proxy-companioninstance (supposedly the only one on the host), and henceDEFAULT_EMAILis used only the first time a certificate is obtained. One can specify the contact address per project by addingLETSENCRYPT_HOSTvariable 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 | lesswhere
--unix-socket- a socket to connect to.jqpretty-prints the resulting json,-Cmakes 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 myappwhere
-m- create the home dir,
-s- specify the shell,
-G- add the user to thedockergroup to be able to start/stop containers, and generally operatedocker.
-
Generate an ssh keypair
$ ssh-keygen -N '' -f ~/.ssh/id_rsawhere
-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
$ bundle exec mina setup # only the first time (once)
$ bundle exec mina deploy
Consider adding the -v flag for verbose output.
TODO