TODO
- pg: use superuser account (create extension)
docker-compose.yml
: prefer dicts over arraysdocker-compose.yml
: no explicit network is needed- add
UID
variable .dockerignore
docker-compose.yml
:environment
afterenv_file
.dockerignore
shouldn't be too strict- prefer
CMD
overENTRYPOINT
- 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.
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) }
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.
.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
trust
s
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
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
.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
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"]
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:
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
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
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
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).
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 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
-
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
withletsencrypt-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 -d
nginx-proxy
isnginx
+docker-gen
. The latter monitors the containers' state, changing thenginx
config when containers get created/destroyed, and reloadingnginx
(makesnginx
proxy requests to the corresponding containers).letsencrypt-nginx-proxy-companion
obtains the certificates. Thecom.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy
label is used to tellletsencrypt-nginx-proxy-companion
which is thenginx-proxy
container. A number of volumes are used to pass information fromletsencrypt-nginx-proxy-companion
tonginx-proxy
, namely, certificates (/etc/nginx/certs
), virtual host settings concerninghttp-01
challanges (/etc/nginx/vhost.d
,location ^~ /.well-known/acme-challenge/ { ... }
), and thehttp-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 perletsencrypt-nginx-proxy-companion
instance (supposedly the only one on the host), and henceDEFAULT_EMAIL
is used only the first time a certificate is obtained. One can specify the contact address per project by addingLETSENCRYPT_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 thedocker
group to be able to start/stop containers, and generally operatedocker
.
-
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
$ bundle exec mina setup # only the first time (once)
$ bundle exec mina deploy
Consider adding the -v
flag for verbose output.
TODO