Michael Cordell's Blog

Dockerized Phoenix Production Migrations

Problem

On production, I have a phoenix web application running in a docker container. This container is connected to a postgres database container via a docker compose. I’d like to be able to run the migration for a new image before updating the running image.

Solution

I created a script that will run the migrations in a side container without using docker compose. This allows migrations to be run for an image that exists in the docker cache but may not be running. Example workflow:

# build the docker image locally
$ docker build -t mcordell/notes:0.2.1 .

# push to docker image to production cache
$ docker save mcordell/notes:0.2.1 | ssh REMOTE_HOST 'docker load'

$ ssh REMOTE_HOST

$(production) ./migrate_app mcordell/notes:0.2.1

Before the script can be created, a module has to be created in the application to run the migrations since we do not have mix:

defmodule Notes.Release do
  @app :notes

  def migrate do
    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.load(@app)
    Application.fetch_env!(@app, :ecto_repos)
  end
end

With this in place we can then run migrations by running Notes.Release.migrate. We can now create a bash script that will load run that code in a passed image:

#! /bin/bash
# Production script for migrating
source .env
docker run -it \
--network root_backend \
-e MIX_ENV="prod" \
-e APP_PORT=5000 \
-e DB_HOST="db" \
-e DB_USER="${DB_USER}" \
-e DB_PASSWORD="${DB_PASS}" \
-e APP_DATABASE="$DB_NAME" \
-e SECRET_KEY_BASE="${SECRET_KEY_BASE}" \
$1 /app/bin/notes eval "App.Release.migrate"

Note that this script is passing necessary environmental variables and connecting to the network specified in the docker-compose (see below). Also, this script assumes the built binary resides at /app/bin/app_name within the docker container. Make the script executable with chmod +x and then it can be used like:

./migrate_app IMAGE_NAME

Context

version: "3"

services:
  db:
    image: postgres:12.3-alpine
    environment:
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_HOST=db
      - POSTGRES_DB=${DB_NAME}
    volumes:
      - /data/pg:/var/lib/postgresql/data
    networks:
      - backend
  app:
    image: mcordell/notes:0.2.0
    expose:
     - 5000
    environment:
      HTTP_PORT: 5000
      MIX_ENV: prod
      APP_PORT: 5000
      DB_HOST: db
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASS}
      APP_DATABASE: ${DB_NAME}
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
    working_dir: /app
    networks:
      service_network:
      backend:
    command: /app/bin/notes start
    depends_on:
      - db

networks:
  service_network:
  backend:
    driver: bridge
    internal: true