Introduction
These are the configurations I use for a Phoenix 1.4 app to deploy it to production using docker-compose contexts. If you don’t have a server ready or docker
and docker-compose
installed on your local dev machine then have a read through that blog post first.
For this build I’m not going to dockerize NGINX as I have it installed on the server.
Create a Dockerfile
Step one is to create a Dockerfile
in the root directory of your project. For this build I’ll be using elixir:X.X.X-alpine image
. alpine
is a much slimmer Linux docker image and will reduce our overall image sizes.
# ./Dockerfile
# Extend from the official Elixir image
FROM elixir:1.10.2-alpine
# Install required libraries on Alpine
# note: build-base required to run mix “make” for
# one of my dependecies (bcrypt)
RUN apk update && apk upgrade && \
apk add postgresql-client && \
apk add nodejs npm && \
apk add build-base && \
rm -rf /var/cache/apk/*
# Set environment to production
ENV MIX_ENV prod
# Install hex package manager and rebar
# By using --force, we don’t need to type “Y” to confirm the installation
RUN mix do local.hex --force, local.rebar --force
# Cache elixir dependecies and lock file
COPY mix.* ./
# Install and compile production dependecies
RUN mix do deps.get --only prod
RUN mix deps.compile
# Cache and install node packages and dependencies
COPY assets/package.json assets/
RUN cd assets && \
npm install
# Copy all application files
COPY . ./
# Run frontend build, compile, and digest assets
RUN cd assets/ && \
npm run deploy && \
cd - && \
mix do compile, phx.digest
# Run entrypoint.sh script
RUN chmod +x entrypoint.sh
CMD ["/entrypoint.sh"]
You’ll want to defer the line COPY . .
as late as possible in the script which will allow docker
to cache dependencies and compiled files without having to install and compile them every time.
Note that we’ve set the environment to production
with ENV mix_env prod
.
Create a docker-compose.yml file
Next create a docker-compose.yml
file in the root directory of your project. To find out which version you can define at the top of your file check docker -v
and compare to the version reference here.
version: "3.7"
services:
phoenix:
build:
context: .
env_file:
- docker.env
environment:
DATABASE_URL: ecto://postgres:postgres@db/your_db_name
DATABASE_USER: postgres
DATABASE_PASS: postgres
DATABASE_NAME: your_db_name
DATABASE_PORT: 5432
DATABASE_HOST: db
ports:
- "4000:4000"
restart: always
depends_on:
- db
db:
image: postgres:10.12-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGDATA: /var/lib/postgresql/data/pgdata
restart: always
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
I’ve set both phoenix
and db
(PostgreSQL) to always restart with restart: always
. If the container crashes or you reboot the server these services will start up automatically.
The Phoenix app depends on the database being up to run, we’ve set this with the depends on: - db
line. We’re also creating a pgdata
volume so the database data is persisted.
Note that we’re using an env_file:
to set some environment variables (in this case only the SECRET_KEY_BASE
) as well as environment:
for other variables. You can choose which variables you want committed to your version control system and which not to commit.
Create a .env file
Now we’ll create the docker.env
file, you can name the file anything as long as it matches the configuration in your docker-compose.yml
file.
SECRET_KEY_BASE=your_secret_key_base
Generate a SECRET_KEY_BASE
with mix phx.gen.secret
and copy it in.
Add the .env file to your .gitignore
We don’t want to commit our secret keys to a version control system so add the following to your .gitignore
file:
# Environment variables
*.env
Create a .dockerignore file
As a starting point you can copy your .gitignore
contents over. When copying our files into the docker
container we don’t want folders such as _build
, assets/node_modules
or deps
to be copied over. Also we don’t want our .env
files copied in.
You can use the file below as an example:
# The directory Mix will write compiled artifacts to.
_build
# If you run "mix test --cover", coverage assets end up here.
cover
# The directory Mix downloads your dependencies sources to.
deps
# Where 3rd-party dependencies like ExDoc output generated docs.
doc
# Ignore .fetch files in case you like to edit your project deps locally.
.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
your_app-*.tar
# If NPM crashes, it generates a log, let's ignore it too.
npm-debug.log
# The directory NPM downloads your dependencies sources to.
assets/node_modules
# Since we are building assets from assets/
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
priv/static
# Ignore Dockerfile
Dockerfile
# Environment variables
*.env
# Git ignore file
.gitignore
# To prevent storing dev/temporary container data
*.csv
tmp
Create an entrypoint file
Create an entrypoint.sh
file in the project’s root directory:
#!/bin/sh
# Note: !/bin/sh must be at the top of the line,
# Alpine doesn't have bash so we need to use sh.
# Docker entrypoint script.
# Don't forget to give this file execution rights via `chmod +x entrypoint.sh`
# which I've added to the Dockerfile but you could do this manually instead.
# Wait until Postgres is ready before running the next step.
while ! pg_isready -q -h $DATABASE_HOST -p $DATABASE_PORT -U $DATABASE_USER
do
echo "$(date) - waiting for database to start."
sleep 2
done
print_db_name()
{
`PGPASSWORD=$DATABASE_PASS psql -h $DATABASE_HOST
-U $DATABASE_USER -Atqc "\\list $DATABASE_NAME"`
}
# Create the database if it doesn't exist.
# -z flag returns true if string is null.
if [[ -z print_db_name ]]; then
echo "Database $DATABASE_NAME does not exist. Creating..."
mix ecto.create
echo "Database $DATABASE_NAME created."
fi
# Runs migrations, will skip if migrations are up to date.
echo "Database $DATABASE_NAME exists, running migrations..."
mix ecto.migrate
echo "Migrations finished."
# Start the server.
exec mix phx.server
Although we’ve set depends_on: - db
in our docker-compose.yml
file, this only waits for the PostgreSQL container to run, however the database will still be starting up and we don’t want to try running any migrations etc. until it’s ready to accept connections hence the while ! pg_isready
line.
Note: in the
print_db_name()
method we need to pass the database password as an environment variable. If you rename$DATABASE_PASS
to$PGPASSWORD
you won’t need to supply it in this file.
Note: Alpine doesn’t have
bash
installed so make sure yourentrypoint.sh
is ashell
script, or you can addapk add bash
to yourDockerfile
.
Configure Phoenix production settings
Most of the environment variables are set in the docker-compose.yml
file which means they’ll only be available to the app at runtime and not when the image is being built.
I was having an issue with the environment variables not being available when using the database_url = System.get_env("DATABASE_URL") || raise ...
syntax, however the build worked just fine when using System.get_env("DATABASE_URL")
inside the config :your_app, ...
function.
The following is my prod.secret.exs
file:
# database_url =
# System.get_env("DATABASE_URL") ||
# raise """
# environment variable DATABASE_URL is missing.
# For example: ecto://USER:PASS@HOST/DATABASE
# """
config :your_app, YourApp.Repo,
username: System.get_env("DATABASE_USER"),
password: System.get_env("DATABASE_PASS"),
database: System.get_env("DATABASE_NAME"),
hostname: System.get_env("DATABASE_HOST"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
database_url: System.get_env("DATABASE_URL")
# secret_key_base =
# System.get_env("SECRET_KEY_BASE") ||
# raise """
# environment variable SECRET_KEY_BASE is missing.
# You can generate one by calling: mix phx.gen.secret
# """
# Comment out secret_key_base, we've added it to prod.exs:
config :your_app, YourAppWeb.Endpoint,
http: [
port: String.to_integer(System.get_env("PORT") || "8080"),
transport_options: [socket_opts: [:inet6]]
],
secret_key_base: System.get_env("SECRET_KEY_BASE")
Testing the build in development
To test everything is working properly before deploying you can run docker-compose up --build
and visit http://localhost:4000
.
Note that this will run your app in prod
mode so If you want a more elegant solution you can create multiple compose files and pass in your build arguments via the compose file. First add an args
section nested under build
in docker-compose.yml
:
build:
context: .
args:
mix_env: dev
get_deps: deps.get
...
And in another compose file (e.g. docker-compose.prod.yml
) change the arguments:
build:
context: .
args:
mix_env: prod
get_deps: deps.get --only prod
...
And then pass the arguments to the Dockerfile
:
# ...
# Build arguments
ARG mix_env
ARG get_deps
# Set environment to production
ENV MIX_ENV ${mix_env}
# ...
# Install & compile production dependecies
RUN mix do ${get_deps}
RUN mix deps.compile
# ...
You will probably need multiple .env
files as well, one for production and one for development.
You can now run different builds depending on which compose file you use e.g. with:
docker-compose -f docker-compose.prod.yml up -d
Deploying the app
Follow the steps here to deploy your app and configure NGINX reverse proxy, SSL certificates and add a custom domain name.
If your server is already set up you can skip the first half of the post and follow along from the deployment stage.