Dockerizing a Phoenix app with a PostgreSQL database

Posted by in DevOps, updated on

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 your entrypoint.sh is a shell script, or you can add apk add bash to your Dockerfile.

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.

Recent Posts