Deploy a Phoenix 1.6 application to Digital Ocean in a Docker container

Deploy a Phoenix 1.6 application to Digital Ocean in a Docker container

Deploy a Phoenix 1.6 application to Digital Ocean in a Docker container

The end result will be a basic Phoenix application with PostgreSQL. The Phoenix application will run on Ubuntu.

Services used are as follows:

  • GitHub for source control
  • GitHub Actions for CI/CD
  • Docker Hub for storing and managing container images
  • Digital Ocean for application hosting

The usual disclaimers apply for all of this info. These are basically my notes on the subject and I hope they are helpful. Your mileage may vary and I’m not responsible for your deployment and your data.



A basic understanding of Docker, Elixir, and Phoenix is required. Docker Desktop must be installed and running. Your Phoenix development environment is configured on your local machine.

All of these steps were performed on macOS Monterey v12.5.1 with an Intel chip. The operating system shouldn’t matter too much, but I can’t say for sure.

Create a sample app

Assuming your environment is set up, first create a new Phoenix app called “Hello,” following the instructions in the Phoenix Up and Running guide.

Make sure the Hello app runs and that the tests pass.

Add Tailwind CSS

Just follow the steps in the installation guide for Phoenix

Add Apline.js

There is a decent guide here. I won’t reproduce all of it here.

I did use NPM instead of yarn as my package manager.

cd assets
npm install alpinejs

I also had to make these change in /assets/js/app.js. The import is slightly differently than suggested in the guide.

// ..snip
// Alpine JS support
import 'alpinejs'
window.Alpine = Alpine

// ..snip

let liveSocket = new LiveSocket("/live", Socket, {
    params: {_csrf_token: csrfToken},
    // Added to prevent LiveView from stepping on Alpine.js
    dom: {
        onBeforeElUpdated(from, to) {
          if (from._x_dataStack) {
            window.Alpine.clone(from, to)

// .. snip

Prepare the app for release

Run the generator to create the release scripts and the release.ex

The mix command has a flag --docker, enabling the generation of a Dockerfile and a .dockerignore file. We won’t use the generated files so that that flag can be omitted.

There is more information on deploying Phoenix in the guides.

We won’t worry about secrets and setting environment variables for the build yet. Since we will deploy the release with Docker, we will use a .env file in our docker-compose.yml to set these. More on this later.


Here is the code at this point.

Build the docker image


The Dockerfile and .dockerignore specify how the release should be built inside of the Docker container

Add the Dockerfile to the root directory of the app.

# File: hello/Dockerfile

# Use the HexPM docker image for building the release
# This Dockerfile is based on the following images:
#  -
#  -
# Ubuntu 22.04.1 LTS (Jammy Jellyfish)
ARG UBUNTU_VERSION=jammy-20220428

# Run the release with the same version of vanilla Ubuntu

# The directory where the application will be built in the builder image
# In the runner image, it will be run in a system users home directory
# in a subdirectory with the same name

FROM ${BUILDER_IMAGE} as builder

# install build dependencies
RUN apt-get update -y \
    && apt-get install -y build-essential git \
    && apt-get install -y curl \
    && apt-get clean \
    && rm -f /var/lib/apt/lists/*_*

# Install Node.js LTS (v16.x)
RUN curl -fsSL | bash - &&\
    apt-get install -y nodejs

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

# prepare build dir

# set build ENV
ENV MIX_ENV="prod"

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

COPY priv priv
COPY assets assets
COPY lib lib

# compile assets
RUN cd assets \
    && npm ci
RUN mix assets.deploy
# Compile the release
RUN mix compile

# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/

COPY rel rel
RUN mix release


# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE} as runner
ENV MIX_ENV="prod"

RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV USER="elixir"

# Creates an system user to be used exclusively to run the app
# This user has the shell /usr/sbin/nologin
RUN adduser --system --group --uid=1000 ${USER}
# Make a directory to hold the release, in the system users home directory
RUN mkdir "/home/${USER}${APP_ROOT}"
# Give the user ownership of its home directory
RUN chown -R "${USER}:${USER}" "/home/${USER}"

# Everything from this line onwards will run in the context of the system user.

# Copy the release to the system users home directory
COPY --from=builder --chown="${USER}":"${USER}" ${APP_ROOT}/_build/${MIX_ENV}/rel/${APP_NAME} ./

CMD ./bin/server

Notice that this uses a multistage build. The first stage builds the release, and the second copies that release into a vanilla Ubunto image for hosting. This image won’t contain all the dependencies we needed to build the release. For example, it won’t have Node.js installed. This makes our deployment smaller and more secure.

Changes from the generated Docker file to note.

  • This file uses Ubuntu instead of Debian. This is a personal preference.
  • NodeJS is installed during the build stage to support Tailwind CSS and Alpine.js
  • Additional ARGs have been added for the APP_NAME and APP_ROOT to make it easier to use this file for another project.
  • Rather than running the application as nobody, a system user account is created, and the application is run under that user. This is different than the Dockerfile generated by Phoenix. You can learn a bit more about this issue here and here. It seemed like the right thing to do, so I added it.


Next, add the .dockerignore to the root directory of the app.

# This file excludes paths from the Docker build context.
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
# There are multiple reasons to exclude files from the build context:
# 1. Prevent nested folders from being copied into the container (ex: exclude
#    /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
# More information on using .dockerignore is available here:


# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc

# docker files
# Common development/test artifacts

# Dev tooling

# Mix artifacts

# Generated on crash by the VM

# Static artifacts - These should be fetched and built inside the Docker image

I added a few items which aren’t in the generated version that show up in my projects that I don’t need to build the release.

Build the image

Build the release image and tag it with the following command.

If all goes well, you should see something like this.

[+] Building 74.4s (33/33) FINISHED                                                                                                                                                                                
 => [internal] load build definition from Dockerfile                                                                                                                                                          0.0s
 => => transferring dockerfile: 2.92kB                                                                                                                                                                        0.0s
 => [internal] load .dockerignore                                                                                                                                                                             0.0s
 => => transferring context: 1.42kB                                                                                                                                                                           0.0s
 => [internal] load metadata for                                                                                                                                      1.0s
 => [internal] load metadata for                                                                                                            1.1s
 => [auth] hexpm/elixir:pull token for                                                                                                                                                   0.0s
 => [auth] library/ubuntu:pull token for                                                                                                                                                 0.0s
 => [builder  1/19] FROM                                            0.0s
 => [internal] load build context                                                                                                                                                                             0.0s
 => => transferring context: 28.74kB                                                                                                                                                                          0.0s
 => [runner 1/6] FROM                                                                         0.0s
 => CACHED [runner 2/6] RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales     && apt-get clean && rm -f /var/lib/apt/lists/*_*                                               0.0s
 => CACHED [runner 3/6] RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen                                                                                                                      0.0s
 => CACHED [runner 4/6] WORKDIR /app                                                                                                                                                                          0.0s
 => CACHED [runner 5/6] RUN chown nobody /app                                                                                                                                                                 0.0s
 => CACHED [builder  2/19] RUN apt-get update -y     && apt-get install -y build-essential git     && apt-get install -y curl     && apt-get clean     && rm -f /var/lib/apt/lists/*_*                        0.0s
 => CACHED [builder  3/19] RUN curl -fsSL | bash - &&    apt-get install -y nodejs                                                                                     0.0s
 => CACHED [builder  4/19] RUN mix local.hex --force &&     mix local.rebar --force                                                                                                                           0.0s
 => CACHED [builder  5/19] WORKDIR /app                                                                                                                                                                       0.0s
 => [builder  6/19] COPY mix.exs mix.lock ./                                                                                                                                                                  0.1s
 => [builder  7/19] RUN mix deps.get --only prod                                                                                                                                                              2.7s
 => [builder  8/19] RUN mkdir config                                                                                                                                                                          0.4s
 => [builder  9/19] COPY config/config.exs config/prod.exs config/                                                                                                                                            0.0s 
 => [builder 10/19] RUN mix deps.compile                                                                                                                                                                     53.6s 
 => [builder 11/19] COPY priv priv                                                                                                                                                                            0.0s 
 => [builder 12/19] COPY assets assets                                                                                                                                                                        0.0s 
 => [builder 13/19] COPY lib lib                                                                                                                                                                              0.0s 
 => [builder 14/19] RUN cd assets     && npm ci                                                                                                                                                               1.4s 
 => [builder 15/19] RUN mix assets.deploy                                                                                                                                                                    10.0s 
 => [builder 16/19] RUN mix compile                                                                                                                                                                           1.4s 
 => [builder 17/19] COPY config/runtime.exs config/                                                                                                                                                           0.0s 
 => [builder 18/19] COPY rel rel                                                                                                                                                                              0.0s 
 => [builder 19/19] RUN mix release                                                                                                                                                                           2.3s 
 => [runner 6/6] COPY --from=builder --chown=nobody:root /app/_build/prod/rel/hello ./                                                                                                                        0.3s 
 => exporting to image                                                                                                                                                                                        0.5s 
 => => exporting layers                                                                                                                                                                                       0.4s 
 => => writing image sha256:1bbad537c2eecc273106c74c2e6fe72d02964f2db8701a676493041b50a1f095                                                                                                                  0.0s 
 => => naming to

Docker Compose

Docker Compose allows us to add a PostgreSQL server for our application quickly and easily. For this deployment we will be running both the application and its database on the same Digital Ocean Droplet.

Next, add the docker-compose.yml

version: '3.8'

    driver: overlay
    attachable: true

    image: postgres:14.5
      replicas: 1
        constraints: [node.role == manager]
        condition: on-failure
      - "postgres_data:/var/lib/postgresql/data"
      - config/docker.env
      - webnet

    image: hello_app:0.1.0
      replicas: 1
        condition: on-failure
      - "80:4000"
      - db
      - config/docker.env
      - webnet

Docker will use the image we just built to run the app. The app will be available on port 80. Note that the data for the PostgreSQL server will be stored in a Docker volume.

Also, take a look at the env_file directive. We will manage the application's secrets via environment variables declared in a docker.env file. Secrets management is a complex topic and I’m not going to discuss it in detail here. Be careful!

Finally, notice depends_on specified for the application server. This causes the application to start after the database. I’ve seen examples where they use an entry point script to wait for PostgreSQL to start. In fact, I had done this previously too. I believe this is no longer necessary and can be omitted.

Setting environment variables

Now, add the docker.env file to the config folder.

!!Warning!! For your application, the docker.env file must never be committed to Git. It holds your application secrets. It is a good idea to ensure that your version of this file has an entry in your .gitignore file in your Git repository so it is not accidentally committed. It would also be a good idea to back it up somewhere too. I use 1Password for that. That said, best practices for secrets management are beyond our scope here.

# Configuration values for the postgres database


# Phoenix configuration values
# see /config/runtime.exs for more

# Configure the phoenix connection to the database via ecto
# For example: ecto://USER:PASS@HOST/DATABASE
# Must match the POSTGRES values above
# The secret key base is used to sign/encrypt cookies and other secrets.
# Generate a new one via mix phx.gen.secret and paste it here
# The port phoenix will be bound to
# The host, used for generating URLs
# Set the log level. The default is info
# The supported levels, ordered by importance, are:
#   :emergency - when system is unusable, panics
#   :alert - for alerts, actions that must be taken immediately, ex. corrupted database
#   :critical - for critical conditions
#   :error - for errors
#   :warning - for warnings
#   :notice - for normal, but significant, messages
#   :info - for information of any kind
#   :debug - for debug-related messages
# For example, :info takes precedence over :debug. If your log level is set to :info, then all :info, :notice and above will be passed to backends. If your log level is set to :alert, only :alert and :emergency will be printed.

I added PHX_LOG_LEVEL as a way to change the verbosity of the logger without rebuilding the image.

Test the deployment locally in Docker

We will start the application and PostgreSQL using Docker Swarm.

docker swarm init --advertise-addr --listen-addr

This initializes the swarm. We set the advertise-addr and listen-addr to the loopback address as we don’t need the swarm to communicate outside of the host. This is contrary to some of the examples I’ve seen where they use the public IP of the Droplet. I think this is more secure and better fits this use case.

docker stack deploy -c docker-compose.yml hello_app --with-registry-auth

Start the app as a stack named hello_app using our docker-compose file. The with-registry-auth flag is needed when updating the service for the Phoenix application with a new Docker image for a new build.

docker service ls

List all of the currently running services.

docker service logs -f hello_app_web

Tail the log of our Phoenix application.

docker swarm leave --force

Stop the swarm and shut down the servers.

Here’s what you should see if you start the application using Docker Swarm locally.

phx_hello % docker swarm init --advertise-addr --listen-addr && docker stack deploy -c docker-compose.yml hello_app && docker service logs -f hello_app_web
Swarm initialized: current node (o7wr9t65bek2083xax1ufma15) is now a manager.

To add a worker to this swarm, run the following command:

    docker swarm join --token SWMTKN-1-3pz6km8lxytf4it89sa3as5c99m1gp4bcz9wdqwyqr6gdq7c2b-479aagrijui0a8c9sozhwj8j6

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.

Creating network hello_app_webnet
Creating service hello_app_web
Creating service hello_app_db
hello_app_web.1.ypj07nnxla03@docker-desktop    | 20:21:39.287 [info] Running HelloWeb.Endpoint with cowboy 2.9.0 at :::4000 (http)
hello_app_web.1.ypj07nnxla03@docker-desktop    | 20:21:39.327 [info] Access HelloWeb.Endpoint at https://localhost
hello_app_web.1.ypj07nnxla03@docker-desktop    | 20:23:43.687 request_id=FxUiXc83YCSDHbIAAAAD [info] GET /
hello_app_web.1.ypj07nnxla03@docker-desktop    | 20:23:43.687 request_id=FxUiXc83YCSDHbIAAAAD [debug] Processing with HelloWeb.PageController.index/2
hello_app_web.1.ypj07nnxla03@docker-desktop    |   Parameters: %{}
hello_app_web.1.ypj07nnxla03@docker-desktop    |   Pipelines: [:browser]
hello_app_web.1.ypj07nnxla03@docker-desktop    | 20:23:43.694 request_id=FxUiXc83YCSDHbIAAAAD [info] Sent 200 in 7ms

Open a web browser and navigate to localhost, and you will see the application running.

Hello application running locally
Hello application running locally

Deploy to Digital Ocean

Build an image and push it to Docker Hub

First, create a repository on Docker Hub. I’m not going to explain how to do that here. Exactly how you use Docker Hub will depend on your needs and your organization.

I’m using a private repository because that’s what I intend to use for my production applications. So everything going forward assumes a private repository. However, I believe this should still work with a public repository.

The next step is to build an image of the application and push it up to Docker Hub so that it can be deployed to the Digital Ocean Droplet.

docker build -t codelever/hello_app:0.1.0 .

Note the change to the tag. This matches the tag name of the repository on Docker Hub.

Now push the image to Docker Hub.

docker push codelever/hello_app:0.1.0

You should see something like this.

phx_hello % docker push codelever/hello_app:0.1.0
The push refers to repository []
b7eaa0502d58: Pushed 
6e8880215c98: Pushed 
09c98fabf1f5: Pushed 
f2cd6d5508ec: Pushed 
485a02fdc6cf: Pushed 
e59fc9495612: Mounted from codelever/test_app 
0.1.0: digest: sha256:054cd250826e23df71047777851a5d371f53b5db70297f5a8dc2f66586c5ff09 size: 1575

You should also be able to view your new image tag on Docker Hub.


Create a Droplet on Digital Ocean

Now, create a Droplet on Digital Ocean to host the application and its database. Again, I’m not going to attempt to explain how to create a Digital Ocean account or manage Droplets. There are other, better resources for that, and it is beyond our scope.

To make the Droplet, there is a Droplet 1-Click app in the marketplace that makes this very easy. You can find it here:

For this test, I selected the Basic plan with the lowest cost.


I used a new SSH key for authentication. Server authentication and SSH keys is its own complex topic. I won’t cover that here. I did also enable Monitoring.

Once the Droplet is created, SSH into it as root. Remember to substitute the IP for your Droplet.

ssh root@

Once you are logged in to the Droplet, upgrade it.

apt-get upgrade -y

If you like, you can also install the Digital Ocean Metrics Agent. It looks like it didn’t work as part of the initial setup for me, even though I checked the box when creating the Droplet.

curl -sSL | sudo bash

On the Droplet, create a directory to hold the Docker config files. These will be the docker-compose.yml and docker.env files from the project.

mkdir -p /etc/hello/config

Now log out of the Droplet and copy the Docker config files to the Droplet. From your local machine, run the following. Again, remember to substitute the IP address for your Droplet.

scp ./config/docker.env root@
scp ./docker-compose.yml root@

Start the application on the Droplet

Make sure you are logged into the Droplet.

Because we are using a private repository on Docker Hub. We need to log in so the deploy command can grab our image from the private repository.

docker login

Then we can start Docker Swarm on the Droplet, just as we did locally.

docker swarm init --advertise-addr --listen-addr

Now we can start the stack, using the docker-compose.yml we previously copied up to the Droplet from the project.

docker stack deploy -c /etc/hello/docker-compose.yml hello_app --with-registry-auth

Check the log to make sure the application started.

docker service logs -f hello_app_web

Then navigate to the Droplet in a browser and make sure the application is running.

The Hello app running in Docker on Digital Ocean
The Hello app running in Docker on Digital Ocean

If things aren’t working right, you can run the docker-compose to get more information. This will provide additional information that docker stack deploy does not when things go wrong.

docker-compose -f /etc/hello/docker-compose.yml up


Here is the code at this point.

Deploy an update

We will make a small change to the Hello application and push it out to the Droplet via Docker and Dockerhub.

Change Hello’s welcome page from “Hello Phoenix” to “Hello Docker.” This way, we can see right away if our update was successful.

.. snip
<section class="phx-hero">
  <h1><%= gettext "Welcome to %{name}!", name: "Docker" %></h1>
  <p>Peace of mind from prototype to production</p>
.. snip

Fix the test.

defmodule HelloWeb.PageControllerTest do
  use HelloWeb.ConnCase

  test "GET /", %{conn: conn} do
    conn = get(conn, "/")
    assert html_response(conn, 200) =~ "Welcome to Docker!"

Locally, build a new image with an updated tag.

docker build -t codelever/hello_app:0.2.0 .

Push the updated image to Dockerhub.

docker push codelever/hello_app:0.2.0

Now SSH back into the droplet.

ssh root@

Take a look at the services running on the Droplet in the swarm.

docker service ls

You should see something like this.

root@hello:~# docker service ls
ID             NAME            MODE         REPLICAS   IMAGE                       PORTS
sfi536epno04   hello_app_db    replicated   1/1        postgres:14.5               
ry35jko90r6d   hello_app_web   replicated   1/1        codelever/hello_app:0.1.0   *:80->4000/tcp

Now, update the hello application service in the swarm with the new image containing our updated release.

docker service update --image codelever/hello_app:0.2.0 hello_app_web

You should see the following.

root@hello:~# docker service update --image codelever/hello_app:0.2.0 hello_app_web
image codelever/hello_app:0.2.0 could not be accessed on a registry to record
its digest. Each node will access codelever/hello_app:0.2.0 independently,
possibly leading to different nodes running different
versions of the image.

overall progress: 1 out of 1 tasks 
1/1: running   [==================================================>] 
verify: Service converged

I’m not sure what the cause of the error is, but the process still succeeds. I spent some time trying to fix it but was unsuccessful. If anyone has any idea what the cause of this error is and how to fix it, please let me know.

Check the status of the swarm’s services.

docker service ls

It looks like everything is running.

root@hello:~# docker service ls
ID             NAME            MODE         REPLICAS   IMAGE                       PORTS
sfi536epno04   hello_app_db    replicated   1/1        postgres:14.5               
ry35jko90r6d   hello_app_web   replicated   1/1        codelever/hello_app:0.2.0   *:80->4000/tcp

Once again, check the log to make sure the application started and running.

docker service logs -f hello_app_web

Finally, open a browser and ensure the update is live on the Droplet.

The Hello App 0.2.0 is live
The Hello App 0.2.0 is live


At this point, we have the basic Hello app, with Tailwind CSS, Alpine.js, and LiveView backed by PostgreSQL running on Digital Ocean in Docker. We can easily build new releases and deploy them via Docker Hub.

If you want, you can now set up a CI/CD pipeline from GitHub to Digital Ocean.


This series was very helpful.

These posts were extremely helpful. All of the articles in this series are worth reviewing.

Same with this series.