How to run your Phoenix application with Docker
Table of contents
- Use Docker to package your Phoenix/Elixir application without requiring users to install Elixir
- Create a Dockerfile extending the official Elixir image
- Use docker-compose.yml to orchestrate Phoenix and Postgres containers
- Handle database initialization with an entrypoint script that waits for Postgres
At Nutrient, we invested in Elixir very early. Nutrient Document Engine is written completely in Elixir, and it delivers real-time collaboration features to our Instant component. It was clear from the beginning that our customers wanted to host Document Engine within their existing infrastructure to retain complete control over their data. We obviously don’t want them to have to install and maintain Elixir and other dependencies on their server, so we decided to use Docker, which provides an additional layer of abstraction of operating-system-level virtualization.
This blog post will explain how you can run your Elixir application inside Docker. This is useful if you’re developing an API that your app developers are using: They don’t need to install Elixir; rather, they can install Docker and run your container. For simplicity’s sake, this post will use the Phoenix framework(opens in a new tab) to serve a website from the Docker container.
What is Docker?
Docker(opens in a new tab) makes it possible to package your application and use it in development and production by running it in a container. A container is like a virtual machine, but while the virtual machine actually runs its own OS, a container can reuse the underlying Linux kernel while still running completely isolated from its surroundings. This not only makes it possible to run many more containers on a single machine compared to virtual machines, but there’s the added benefit that containers start instantly.
Getting started
This post assumes you already have a Phoenix project you want to run within a Docker container. To get started with Docker, you first need to install(opens in a new tab) it for your environment.
Dockerfile
A container is an instance of a Docker image. You can create your own images and start from a clean Linux distribution of your choice and install all required packages on your own, or you can use predefined images from Docker Hub(opens in a new tab). For this Elixir project, you can use the official Elixir Docker image(opens in a new tab) as your base image. The Elixir image itself uses an Erlang image as its base and extends it by installing the required Elixir dependencies(opens in a new tab).
Now create your own Dockerfile in your Elixir root directory:
# Extend from the official Elixir image.FROM elixir:latest
# Create app directory and copy the Elixir projects into it.RUN mkdir /appCOPY . /appWORKDIR /app
# Install Hex package manager.# By using `--force`, we don't need to type "Y" to confirm the installation.RUN mix local.hex --force
# Fetch and compile dependencies.RUN mix deps.getRUN mix compileIn the Dockerfile, you specified what should be done when running the container. You:
- Created a separate
/appfolder, which you copied your code to. - Installed Hex package manager.
- Fetched dependencies and compiled the project.
While writing the Dockerfile, you may have noticed that there is no mention of Postgres. Where should you run your database? If you want all of your infrastructure to run in containers, you should actually create your own container that runs Postgres. Luckily, there’s also an official Postgres image you can reuse. But all of this likely leaves you with more questions than before.
How can you create an image from your Dockerfile now? How can you start multiple containers at once? How can your Phoenix container communicate with the Postgres container?
The solution? Docker Compose.
docker-compose.yml
docker-compose.yml is the file that defines which containers you’ll run and which images you need to create. Here’s the full docker-compose.yml you’ll use for your app:
# Version of docker-compose.version: '3'
# Containers you're going to run.services: # Your Phoenix container. phoenix: # The build parameters for this container. build: # Here you define that it should build from the current directory. context: . environment: # Variables to connect to your Postgres server. PGUSER: postgres PGPASSWORD: postgres PGDATABASE: database_name PGPORT: 5432 # Hostname of your Postgres container. PGHOST: db ports: # Mapping the port to make the Phoenix app accessible outside of the container. - '4000:4000' depends_on: # The DB container needs to be started before you start this container. - db db: # Use the predefined Postgres image. image: postgres:16 environment: # Set user/password for Postgres. POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres # Set a path where Postgres should store the data. PGDATA: /var/lib/postgresql/data/pgdata restart: always volumes: - pgdata:/var/lib/postgresql/data# Define the volumes.volumes: pgdata:Because a container is stateless and can be destroyed and recreated at any time, Docker allows you to create volumes. Volumes are separate from the container, and you can easily define them in docker-compose.yml.
Up and running… nearly
Now it’s time to create your Docker image. You can do this by executing docker-compose build (it’ll take some time to download and install all the images). You should receive a success message, and via docker images, you’ll get a list of all Docker images available on your machine:
REPOSITORY TAG IMAGE ID CREATED SIZEhelloworld_phoenix latest be0fed013a6b 2 minutes ago 917MBelixir latest 9d6cef9afe13 13 days ago 889MBpostgres 16 d3ac03f9698d 4 weeks ago 432MBYou now can even start your Docker container via docker-compose up. However, if you go to http://localhost:4000, you’ll see nothing. This is because, as you may have noticed, you never started your Phoenix application, nor did you create the database.
Starting Phoenix and creating your database
To start your Phoenix server, create your database, and do your database migrations, you’ll create a separate shell script called entrypoint.sh. (When creating this file, don’t forget to give it execution rights via chmod +x entrypoint.sh):
#!/bin/bash# Docker entrypoint script.
# Wait until Postgres is ready.while ! pg_isready -q -h $PGHOST -p $PGPORT -U $PGUSERdo echo "$(date) - waiting for database to start" sleep 2done
# Create, migrate, and seed database if it doesn't exist.if [[ -z `psql -Atqc "\\list $PGDATABASE"` ]]; then echo "Database $PGDATABASE does not exist. Creating..." createdb -E UTF8 $PGDATABASE -l en_US.UTF-8 -T template0 mix ecto.migrate mix run priv/repo/seeds.exs echo "Database $PGDATABASE created."fi
exec mix phx.serverWaiting until Postgres starts
You might ask yourself: I already defined the Postgres container as a dependency of my Phoenix container, so why do I need to wait? Yes, docker-compose will wait until the Postgres container has started, but this doesn’t mean that the Postgres server inside the container is already running. This is why you need to wait until your Postgres server starts via pg_isready — you’ll learn later on how you can install it.
Inside the loop, check if the Postgres server is already running. If that’s not the case, wait two seconds and check the status of the server again. You can also see that you can use the environment variables defined in the docker-compose file for your pg_isready function.
Creating the database
After you know that Postgres has started, you can create your database if it doesn’t yet exist and run your migrations and seed data.
Starting your Phoenix server
In the end, you can execute mix phx.server and this will finally start your server.
Updating your Dockerfile
You now need to execute the entrypoint.sh script in your Dockerfile. You also need to install the postgresql-client package to run pg_isready within that script:
# Use an official Elixir runtime as a parent image.FROM elixir:latest
RUN apt-get update && \ apt-get install -y postgresql-client
# Create app directory and copy the Elixir projects into it.RUN mkdir /appCOPY . /appWORKDIR /app
# Install Hex package manager.RUN mix local.hex --force
# Fetch and compile dependencies.RUN mix deps.getRUN mix compile
CMD ["/app/entrypoint.sh"]Accessing the Postgres server
One question still remains: How can you access the Postgres server within your Phoenix container? The Postgres container is available to you via the hostname db (the same name as the container). To actually use your environment variables in your configuration, you’ll overwrite the database configuration in your Repo module:
defmodule YourApp.Repo do use Ecto.Repo, otp_app: :your_app
def init(_, config) do config = config |> Keyword.put(:username, System.get_env("PGUSER")) |> Keyword.put(:password, System.get_env("PGPASSWORD")) |> Keyword.put(:database, System.get_env("PGDATABASE")) |> Keyword.put(:hostname, System.get_env("PGHOST")) |> Keyword.put(:port, System.get_env("PGPORT") |> String.to_integer) {:ok, config} endendNow you’re finally done. You can run docker-compose build and docker-compose up again, and your containers will start, create the database, and start the Phoenix server that’s now accessible via http://localhost:4000.
Useful commands
Here’s a small list of useful commands when dealing with Docker:
docker pslists all containers that are currently runningdocker container ls --alllists all containers that are availabledocker logs <container>shows the log of the given containerdocker start/stop <container>starts or stops a containerdocker rm <container>removes a containerdocker imageslists all available imagesdocker rmi <image>removes an imagedocker-compose down --volumesdestroys the created volumes
Conclusion
Even if it takes you some time to create the Dockerfile and docker-compose.yml, you’ll benefit from it in the long run. Everyone on our team can now run this Docker container without the need to install any dependencies. It’s also easy to recreate the container if something goes wrong and to reuse it across your test and production environments.
FAQ
To set up a Phoenix application with Docker, create a Dockerfile to define the app environment, use docker-compose to manage dependencies and services, and build your Docker image to run the application in a container.
Docker provides a consistent environment, simplifies deployment, and ensures your Phoenix application runs smoothly across different systems by packaging it with all its dependencies.
Use a Dockerfile to define all necessary dependencies and configurations and docker-compose to manage services like the database, ensuring that everything is isolated and consistently deployed.
A Dockerfile is a script that contains instructions on how to build a Docker image. It’s crucial for defining the environment, dependencies, and configurations needed to run your Phoenix app.
Troubleshoot by checking docker logs for errors, ensuring all dependencies are correctly defined in the Dockerfile, and verifying that all services are up and running as expected with docker-compose.