Containerizing a Phoenix 1.6 Umbrella Project
Packaging and Deploying a Phoenix 1.6 Umbrella Project using Docker
While there are existing guides on how to package a Phoenix application using Docker, most of them deal with ‘regular’ Phoenix apps, not umbrella projects. In this guide, we’ll take our earlier Phoenix 1.6 umbrella project, build a minimal release binary, package it as a Docker image and deploy it as a Docker container.
The accompanying source code to this article is also at https://github.com/aisrael/elixir-phoenix-typescript-react
Now that we’ve seen how to create a Phoenix 1.6 project with TypeScript and React, let’s now explore how to release, package, and deploy it using Docker.
What/Why/How to Release?
An Elixir or Erlang/OTP Release is a self-contained artifact that allows us to run our Elixir application, with minimal dependencies and no source code. I like to think of it as something like a binary executable that contains everything needed to run our application, and nothing more.
In production environments, we don’t really need or want the entire Elixir/Erlang and Node toolchains. Some of those weigh in at dozens, even hundreds of MB and when deploying at scale, every MB saved matters.
Prior to Elixir v1.9, we would need to use Distillery to package and release our Elixir or Phoenix app. Starting with v1.9, however, Elixir provided the ability to mix release
our app without any additional tooling.
We can still use Distillery, especially if we want some of its additional features such as hot code upgrades. Since we’re also looking to deploy our application as a container, however, then hot code upgrading becomes less of a concern.
I won’t go too much into how containers have become the preferred means of packaging and deploying server-side applications and services, or why Docker is the de facto standard, having popularized and made containers accessible in the beginning.
But for any large-scale Web application or service, chances are you’ll want to deploy them as containers using an orchestration platform such as Kubernetes, so this is where we’re headed in this guide.
Phoenix Umbrella Project Releases
The Phoenix framework helpfully provides us with a helper task, mix phx.gen.release
that can even generate a Dockerfile
that serves as a great starting point for us!
However, if we try to run mix phx.gen.release
from the root of a Phoenix umbrella project, we are greeted with:
$ mix phx.gen.release
** (Mix) mix phx.gen.release is not supported in umbrella applications.Run this task in your web application instead.
So, let’s try it in apps/hello_web
instead.
$ cd apps/hello_web
This time, we’ll also supply the --docker
option to get a Dockerfile
we can work with. This creates a few files we’ll be needing:
hello_web$ mix phx.gen.release --docker
* creating rel/overlays/bin/server
* creating rel/overlays/bin/server.bat
* creating rel/overlays/bin/migrate
* creating rel/overlays/bin/migrate.bat
* creating lib/hello_web/release.ex
* creating Dockerfile
* creating .dockerignore...
As well as printing out a few more helpful instructions. At this point, though, if we try mix release
like the instructions suggest, while it will complete successfully and the resulting executable does run — it doesn’t actually run our Web application.
Going Back to Our Root
We need our entire umbrella project, so we need to go back to working from the project root. First, move the generated Dockerfile
and .dockerignore
files into the project root
hello_web$ cd ../..
$ mv apps/hello_web/Dockerfile apps/hello_web/.dockerignore .
From this point forward, we’ll also want to make sure our MIX_ENV
is set to prod
, because we are trying to build a release for production:
$ export MIX_ENV=prod
Now, if we try to mix release
again from the root, we run into:
$ mix release
** (Mix) Umbrella projects require releases to be explicitly defined with a non-empty applications key that chooses which umbrella children should be part of the releases: releases: [
foo: [
applications: [child_app_foo: :permanent]
],
bar: [
applications: [child_app_bar: :permanent]
]
]Alternatively you can perform the release from the children applications
Mix is telling us that we need to configure our umbrella app and tell it what the child applications are.
To do that, we’ll have to edit mix.exs
and add something like the following lines (in bold) to the project definition:
def project do
[
apps_path: "apps",
version: "0.1.0",
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
releases: [
hello_umbrella: [
applications: [
hello: :permanent,
hello_web: :permanent
]
]
]
]
end
At this point, mix release
will complete and build a more or less self-contained executable:
$ MIX_ENV=prod mix release
==> hello
Generated hello app
==> hello_web
Compiling 4 files (.ex)
Generated hello_web app
Release hello_umbrella-0.1.0 already exists. Overwrite? [Yn] y
* assembling hello_umbrella-0.1.0 on MIX_ENV=prod
* using config/runtime.exs to configure the release at runtimeRelease created at _build/prod/rel/hello_umbrella!# To start your system
_build/prod/rel/hello_umbrella/bin/hello_umbrella start
If we do what it says now and try to _build/prod/rel/hello_umbrella/bin/hello_umbrella start
, we’ll quickly discover some runtime configuration errors.
Rather than explain how to address them at this point, let’s skip that for now and try to build our Docker image so we focus on running our Phoenix application as a container.
server: true
One small, but important thing that’s not very well documented and might cause you some level of head-scratching and frustration (it did me) is that somewhere inside config/runtime.exs
, there’s a section that goes
# ## Using releases
#
# If you are doing OTP releases, you need to instruct Phoenix
# to start each relevant endpoint:
#
# config :hello_web, HelloWeb.Endpoint, server: true
Since we are using OTP releases, we will have to comment that last line out. If you don’t everything else below will seem to work, but when you actually try to navigate to your Web application you’ll just keep getting no response!
So make sure you uncomment the last line, as follows:
config :hello_web, HelloWeb.Endpoint, server: true
Tweaking the Dockerfile
While Phoenix was helpful enough to generate a Dockerfile
for us, it also assumes a regular Phoenix project rather than an umbrella project.
Release Builder Stage
Notably, since we’re coming from a project that uses React, we’ll still need npm
but only in the builder stage. So, in the Dockerfile
where it says # install build dependencies
we’ll want to add npm
, like so:
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git npm \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
Next, the generated Dockerfile
only copies mix.exs
and mix.lock
from the project root into the build container. Because we have an umbrella project, we also need to copy in mix.exs
for all child apps.
We can compress all the steps by simply going
COPY . .
but that defeats the purpose of the multiple, explicit steps in the generatedDockerfile
, namely, being able to cache earlier steps that involve infrequently changed files.
Let’s add mix.exs
for all the child apps:
COPY apps/hello/mix.exs ./apps/hello/mix.exs
COPY apps/hello_web/mix.exs ./apps/hello_web/mix.exs
Next, the generated Dockerfile
tries to copy over some folders such as priv
and assets
from the project root, but in our case, those folders actually reside under apps/hello_web/
. We’ll need to modify the following lines accordingly:
# COPY priv priv
COPY apps/hello_web/priv apps/hello_web/priv
And
# COPY assets assets
COPY apps/hello_web/assets apps/hello_web/assets
Now, once again when compiling the assets, we’ll want to modify the Dockerfile
to run the command under apps/hello_web
:
# compile assets
RUN cd apps/hello_web && mix assets.deploy
Almost there! Now, where a typical Phoenix app has all its source under lib
, for an umbrella project we want everything under apps
, so replace the line COPY lib lib
with:
# Compile the release
COPY apps apps
Runner Image Stage
Now we’re done with the release builder stage, we just need a couple of adjustments in the final image stage.
In particular, the generated Dockerfile
was for hello_web
but the actual release name is hello_umbrella
:
# Only copy the final release from the build stageCOPY --from=builder --chown=nobody:root /app/_build/prod/rel/hello_umbrella ./
Finally, the generated Dockerfile
uses the CMD /app/bin/server
, which is problematic.
First since the file app/bin/server
doesn’t actually exist. Rather, what we’ll find there is app/bin/hello_umbrella
.
Second, Docker best practices tell us to prefer ENTRYPOINT
and not just CMD
—this lets us specify program arguments at run time.
So, finally, we replace the last line with:
ENTRYPOINT [ "/app/bin/hello_umbrella" ]
For a quick summary of all the changes in the
Dockerfile
, see this commit.
At this point, we should be able to successfully build and tag our Docker image using:
$ docker build -t hello .
Up and Running!
After our Docker image has completed building, if we just try to run it using docker run hello
we should just see:
Usage: hello_umbrella COMMAND [ARGS]The known commands are: start Starts the system
...
Now, if we try docker run hello start
we’ll get a runtime error telling us that the environment variable DATABASE_URL is missing
, and that it probably should look like ecto://USER:PASS@HOST/DATABASE
.
DATABASE_URL
Recall that in our earlier article we used Docker Compose to run Postgres. While Postgres should be listening on localhost
relative to our workstation, within Docker, a container by default can’t just refer to the localhost
of the host.
Instead, we need to run our container as part of the same Docker internal network that Docker Compose used to run Postgres. To get this network, we have to issue the command docker network ls
, which should give us something like:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
ddd978e072b8 bridge bridge local
0e787c1bff92 hello_umbrella_default bridge local
So our network name is hello_umbrella_default
.
Next, we’ll need to figure out what the hostname of the Postgres server is within Docker — by default, that’s just container name, which we can get by issuing docker ps
:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c69d93a2f9bb postgres:14.1 "docker-entrypoint.s…" 4 seconds ago Up 3 seconds 0.0.0.0:5432->5432/tcp hello_umbrella-postgres-1
So, our Postgres hostname is hello_umbrella-postgres-1
.
Using the default credentials (and the development database), our complete DATABASE_URL
now becomes:
export DATABASE_URL=ecto://postgres:postgres@hello_umbrella-postgres-1/hello_dev
SECRET_KEY_BASE
With DATABASE_URL
out of the way, if we try to run our Docker image again, we’ll get another error message saying environment variable SECRET_KEY_BASE is missing
.
The SECRET_KEY_BASE
is used by Phoenix to sign/encrypt cookies and other secrets. In development, it’s generated at project creation and hard-coded in config/dev.exs
.
In production, however, we don’t want secrets to be committed to source code, and, ideally should only be accessible to operations, if at all.
For now, all we need to do is supply some value, at least 64 bytes long, encoded as Base64. Since we already have Elixir, we can use it generate a suitably encoded random value:
$ export SECRET_KEY_BASE=$(elixir --eval 'IO.puts(:crypto.strong_rand_bytes(64) |> Base.encode64(padding: false))')
Hello, Phoenix from Docker!
Let’s put together our final docker run
command.
Those of you more experienced with Docker can probably craft a
docker-compose.yml
file that avoids having to type out the command below by hand, but the purpose of this guide was to educate the reader about the individual configuration items that are needed to run a Phoenix release as a container.
To recap, we need:
- To specify the network,
--net hello_umbrella_default
- The
DATABASE_URL
variable - The
SECRET_KEY_BASE
variable
We also need to publish the port that our Phoenix app is serving on, 4000
to our localhost
, using -p 4000:4000
.
The final docker run
command thus looks like:
$ docker run -p 4000:4000 --net hello_umbrella_default -e DATABASE_URL=$DATABASE_URL -e SECRET_KEY_BASE=$SECRET_KEY_BASE hello start
If we navigate to http://localhost:4000 now, we should be greeted with our “Welcome to Phoenix with TypeScript and React!” landing page.
I hope this is helpful to people exploring releasing and deploying their Elixir+Phoenix applications as containers.
In the future I hope to find time to write about adding a GraphQL API into the mix.