Deploying phoenix applications using docker and distillery

Posted on 2017-07-05 14:44:48

This post is aimed towards developers who have developed a Phoenix application that they want to deploy to a staging or production system. To ensure deployment to production systems is reliable, repeatable and scalable I am using docker and the mix docker library which uses distillery to package your application in a production environment within a docker image.

The first steps if this deployment are largely covered excellently by this blog post which will get you to the point where your docker images have been created.

However, I have added a few steps so that my database creation, migration and seed commands are run when my application starts. Before starting step 5 and running mix docker.build and mix docker.release you need to configure your release so that you can add your migration and seed functionality.

To do this run the following command to generate your release files.

mix release.init

This will create a rel directory within which you are going to need to make a few changes. You need to create pre and post start boot hooks which will run your commands. To do this open up rel/config.exs and change the environment :prod function to the following.

environment :prod do
  set include_erts: true
  set include_src: false
  set cookie: :"GENERATED_SECRET"
  set pre_start_hook: "rel/hooks/pre_start"
  set post_start_hook: "rel/hooks/post_start"
end

You then need to create the rel/hooks/pre_start and rel/hooks/post_start shell command files. The pre start file will run Elixir.Release.Tasks createdb which creates your database if it doesn’t already exist and the post start script will check when the application has fully started and then call the Elixir.Release.Tasks migrate command from your release binary.

Your pre start script is as follows:

#rel/hooks/pre_start

echo "Running database creation"
bin/my_app command Elixir.Release.Tasks createdb
echo "DB created successfully"

And the post start script:

#rel/hooks/post_start
set +e

while true; do
  nodetool ping
  EXIT_CODE=$?
  if [ $EXIT_CODE -eq 0 ]; then
    echo "Application is up!"
    break
  fi
done

set -e

echo "Running migrations"
bin/my_app rpc Elixir.Release.Tasks migrate
echo "Migrations run successfully"

If you were to run this now it would cause an error as the Elixir.Release.Tasks commands haven’t been written yet. To do this you need to create a file in the lib directory, for example lib/release.ex which will call the database creation and migration commands for your application and run a seed script if you have one created at priv/repo/seeds.exs. Your release script should be as follows.

defmodule Release.Tasks do

  @start_apps [
    :postgrex,
    :ecto
  ]

  @myapps [
    :my_app
  ]

  @repos [
    MyApp.Repo
  ]

  def createdb do
    
    # Ensure all apps have started
    Enum.each(@myapps, fn(x) ->
      :ok = Application.load(x)
    end)
        
    # Start postgrex and ecto
    Enum.each(@start_apps, fn(x) ->
      {:ok, _} = Application.ensure_all_started(x)
    end)
        
    # Create the database if it doesn't exist
    Enum.each(@repos, &ensure_repo_created/1)
        
    :init.stop()
  end

  def migrate do

    IO.puts "Starting dependencies"
    # Start apps necessary for executing migrations
    Enum.each(@start_apps, &Application.ensure_all_started/1)

    # Start the Repo(s) for myapp
    IO.puts "Starting repos"
    Enum.each(@repos, &(&1.start_link(pool_size: 1)))

    # Run migrations
    Enum.each(@myapps, fn(myapp) ->
      run_migrations_for(myapp)
      # Run the seed script if it exists
      seed_script = seed_path(myapp)
      if File.exists?(seed_script) do
        IO.puts "Running seed script for #{myapp}"
        Code.eval_file(seed_script)
      end

    end)

  end

  def priv_dir(app), do: "#{:code.priv_dir(app)}"

  defp run_migrations_for(app) do
    IO.puts "Running migrations for #{app}"
    Ecto.Migrator.run(MyApp.Repo, migrations_path(app), :up, all: true)
  end

  defp migrations_path(app), do: Path.join([priv_dir(app), "repo", "migrations"])
  defp seed_path(app), do: Path.join([priv_dir(app), "repo", "seeds.exs"])

  defp ensure_repo_created(repo) do
    case repo.__adapter__.storage_up(repo.config) do
      :ok -> :ok
      {:error, :already_up} -> :ok
      {:error, term} -> {:error, term}
    end
  end

end

Once these steps have been done you can proceed with the remaining steps in Alex Kovshovik’s post to generate the docker images and you’ll almost be ready to deploy on your server. The only other change I had to make was to add the --net=host flag when running the docker container so that the container could access postgres on the host machine.

For the deployment I am assuming that this will be a lightweight application that will be running with everything on one server with the phoenix application contained within a single docker container. Deployment to a large scale system across multiple servers is beyond the scope of this post but is covered well by Tymon Tobolski.

To deploy your system on a server you will first need to have an account and repo (either private or public) on docker.com so that we can push and pull our images. You may also need to run docker login in order to access your repository.

You will firstly need to tag your images, obviously you’ll need to use your own docker username and repository name.

docker tag my_app:build USERNAME/REPO_NAME:build && docker tag my_app:release USERNAME/REPO_NAME:release

Then you can push your images to the docker cloud.

docker push

Once they have successfully been pushed you can SSH to your server which should have postgres and docker installed. You will only need to pull the release image to your server. Again you may need to run docker login on your server before running:

docker pull USERNAME/REPO_NAME:release

Run the following command to start the container replacing the environment variables with your domain name, database credentials and a secret key which can be generated using mix.gen.secret.

docker run -d --net=host -p 4000:4000 -e PORT=4000 -e HOST=<your-domain-name> -e DB_HOST=localhost -e DB_NAME=<your=db-name> -e DB_USER=<your-db-user> -e DB_PASSWORD=<your-db-password> -e SECRET_KEY_BASE=<your-secret-key> USERNAME/REPO_NAME:release foreground

The application should now be running. To forward traffic to it you will need to configure nginx. My nginx configuration looks like this.

server {
  listen 80;

  server_name yourdomain.com;

  location / {
      proxy_pass http://localhost:4000;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_cache_bypass $http_upgrade;
  }
}

Once nginx is configured and restarted you should be up and running.


Comments