Containerizing a Phoenix 1.6 Umbrella Project

Packaging and Deploying a Phoenix 1.6 Umbrella Project using Docker

Alistair Israel
8 min readJan 4, 2022

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 runtime
Release 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 generated Dockerfile, 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_URLvariable
  • 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.

--

--