Other articles in the series
The backend related code for this post is available here.
The front-end related code for this post is available at here.
The deployment related code for this post is available at here.
Deploying Phoenix VueJS application using Docker
In this part, we will deploy our frontend and backend using docker. During development, we have been using mix to build and run our Phoenix server. When we run our deploy to production, we wont be using mix to run our server. Elixir support releases, which enable to bundle our Phoenix application along with all its dependencies and even the BEAM VM if required. This allows easy deploy to a production machine. There are also many advantages to using release which are explained in detail in official docs
In short, it provides the following benefits. * Code-preloading - This loads all required modules beforehand enabling the system to handle requests as soon as started. * Self-contained - Releases can include BEAM VM, making installation of VM unnecessary on new servers. It also guarantees the correct version of VM. * Configuration - Can be used to tune the system.
From elixir 1.9 onwards releases are built in. Phoenix docs provides a step by step guide to build and deploy phoenix apps. So lets build ourselves a new release.
Initialize a new release
A new release can be initialized as follows.
mix release.init
This will create the rel/ folder at the root. They can be used to tune the system and add configuration. We will get back to this later.
In our config/prod.secret.exs we load the database url and secret key base as environment variables, as shown below.
use Mix.Config
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
The issue with the code is that, this code is executed on the compiling machine and copied over to releases. In simple terms, we get value of DATABASE_URL from the compiling machine and it is not taken from the deployed/production machine. It is a bit strange, but it is true :). Inorder to fix this, releases support runtime configuration. Follow the below steps to convert our prod.secret.exs, so that it is loaded at runtime and not at compile time.
- Rename config/prod.secret.exs to config/releases.exs
- Replace use Mix.Config in config/releases.exs to import Config.
- Remove import_config “prod.secret.exs from config/prod.exs file.
The contents of config/releases.exs will be as follows.
import Config
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
config :ms, Ms.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
config :ms, MsWeb.Endpoint,
http: [:inet6, port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: secret_key_base
As can be seen above, we need to provide values for environment variables SECRET_KEY_BASE and DATABASE_URL.
Phoenix requires SECRET_KEY_BASE to sign and encrypt data, inorder to avoid tampering. We can use the below command to generate the secret and set it as an environment variable in our system.
mix phx.gen.secret
A secret key
export SECRET_KEY_BASE=A secret key
Database url can be set as follows. It follows the pattern ecto://USER:PASS@HOST/database?port=portNo. Since we ran on port 5433, as opposed to 5433 we need to provide the port too.
export DATABASE_URL=ecto://postgres:postgres@localhost/ms_prod?port=5433
Phoenix by default doesn’t start the server with releases. To support this we need to turn on server in config/prod.ex as follows. Just uncomment the below line.
config :ms, MsWeb.Endpoint, server: true
Now we are ready to make our release.
Compiling and build release
Follow the below commands to make release.
# Get dependencies required for production
mix deps.get --only prod
# Compile code
MIX_ENV=prod mix compile
# compile other assets like js/css
mix phx.digest
# Build release
MIX_ENV=prod mix release
Starting the server
# Start the server
_build/prod/rel/ms/bin/ms start
Now we will get an error like below.
15:36:16.079 [error] Could not find static manifest at "/_build/prod/rel/ms/lib/ms-0.1.0/priv/static/cache_manifest.json". Run "mix phx.digest" after building your static files or remove the configuration from "config/prod.exs".
This is because we didn’t run the mix phx.digest. phx.digest task digests and compresses static files. Our static Javascript files are in our client repository. So there are two ways to do this. Either we serve the static files with a server like nginx or we use Phoenix to serve static files too. If we use Phoenix, we have to build the files in our client repository, copy it over to priv/static and run phx.digest. Here we chose the first option. This decision, makes our elixir releases separate from our UI deployments.
Deploying Static files with Nginx and docker
We want nginx to redirect all /api/v1/ requests to Phoenix and all requests to Vue. Lets take a look at our nginx.conf file. Please take a look at the comments too.
server {
listen 80;
# Our hostname, can be changed if hosting on a domain
server_name localhost;
# Root of nginx
root /usr/share/nginx/html/;
# All /api/v1 calls will be redirected to Phoenix
location /api/v1/ {
proxy_pass http://localhost:4000/api/v1/;
proxy_set_header Host "localhost";
}
# Static files served from here
# Using try_files we rewrite all url to /index.html which will be handled by Vue
location /{
try_files $uri /index.html;
}
}
Now lets test-drive this config with an nginx docker container. Before we do this, we need to generate the JS files from client. This can be easily done using npm build.
npm run build
Now we have a dist folder at root of project with all the js/css files. We can directly mount this folder as the root folder for nginx. In docker it can be done as below.
docker run -p 80:80 -v <full absolute path to folder>/dist/:/usr/share/nginx/html/:ro -v <full absolute to nginx folder>/nginx.conf:/etc/nginx/conf.d/default.conf --network="host" nginx
Here we use –network=“host” for testing purposes. This binds the nginx container to our host, so that all ports in host is accessible to nginx container. This is required as our Phoenix is running on our host machine. Once we make sure everything works fine, we will everything to a docker-compose file.
As we can see everything works fine and all our apps run in production mode. Now lets write our docker-compose.yml file and Dockerfile which handles everything from building of binaries to deploying them.
Docker file for Phoenix Server
As you know, when you need to build your own container. You need to write a Dockerfile. This file tells docker what commands to execute to build and run our docker container. Lets take a look at our Dockerfile for backend inside shopmanagementserver folder. Please check the comments inside the file. Here we use docker multi-stage build. This allows us to use multiple temporary containers to build our app and copying only required build artifacts from those temporary containers to our app container.
# Setup first temporary container for building elixir.
FROM elixir:1.9.2-alpine as build-elixir
# Install build dependencies
RUN apk add --update git
# Set our work directory in docker container
RUN mkdir /app
WORKDIR /app
# Install hex and rebar
RUN mix local.hex --force && \
mix local.rebar --force
# set build ENV. This might not be necessary
ENV MIX_ENV=prod
# Install all mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get
# Compile all mix dependencies
COPY config ./config/
RUN mix deps.compile
# Now we selectively copy all required files from local system to docker container
COPY lib ./lib/
COPY priv ./priv/
COPY rel ./rel/
# Run digest. This is not required for us, as we don't serve as static files from phoenix.
RUN mix phx.digest
# Build our release
RUN mix release
# Now this container will have our Elixir release compiled. We just need to copy it to our production container.
# We use multiple containers because, we don't want to have all development tools and files in our production server.
# Production Elixir server
FROM alpine:3.10
# Install openssl and bash
RUN apk add --update bash openssl
RUN mkdir /app
WORKDIR /app
# We run as root, we can change it in the future
USER root
# Copy all build artifacts from previous container. Notice the --from
COPY --from=build-elixir /app/_build/prod/rel/ms ./
# Copy required scripts to run when the container starts
COPY entrypoint.sh .
# Setting environment variables
ARG VERSION
ENV VERSION=$VERSION
ENV REPLACE_OS_VARS=true
# Make our build runnable
RUN chmod 755 /app/bin/ms
# Expose our app to port 4000
EXPOSE 4000
# These two environment variables are checked in phoenix server, to find the secret key and database url.
ENV DATABASE_URL=ecto://postgres:postgres@postgres/ms_prod
ENV SECRET_KEY_BASE=6jSLHKOk3s645E27EZVULIAuopigrSaiTgi+aKz7dtqKw0qRwjKWwQIkXqyyzkZc
# Set script to run when the server starts
CMD ["./entrypoint.sh"]
Contents of shopmanagementserver/entrypoint.sh is as follows.
#!/bin/sh
# Docker entrypoint script.
# Sets up tables and running migrations.
/app/bin/ms eval "Ms.Release.migrate"
# Start our app
/app/bin/ms start
Running Postgres migrations from Docker
You might be wondering why we need this Ms.Release.migrate task in Docker and not before. In previous case we had our postgres database already setup for us by mix ecto.create and mix ecto.migrate. In production system we don’t have access to mix. So we need to write a migrate script in Phoenix app and call it before starting the phoenix app in production. We place the release.ex file inside lib/ms folder. The contents are as follows.
defmodule Ms.Release do
"""
Release script for running migrations. migrate function runs all migrations
"""
@app :ms
def migrate do
# Get all repos and run ecto migrate
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
# loads all repos for our app
defp repos do
Application.load(@app)
Application.fetch_env!(@app, :ecto_repos)
end
end
This can be triggered in release using eval
# Syntax is app_binary eval full_function_path
/app/bin/ms eval "Ms.Release.migrate"
Now when our server starts, it will have executed all migrations and hence will be ready to serve requests.
Dockerfile for VueJS client
Similarly we need to build and run our Client. The contents of shopmanagementclient/Dockerfile is as follows.
# First temporary container to build the vuejs app
FROM node:10.16-alpine as build-node
# prepare build dir
RUN mkdir -p /app/assets
WORKDIR /app
# Manually copy all required files.
COPY package.json package-lock.json ./assets/
COPY vue.config.js ./assets/vue.config.js
COPY src ./assets/src/
COPY .env ./assets/.env
COPY babel.config.js ./assets/babel.config.js
COPY postcss.config.js ./assets/
COPY tsconfig.json ./assets/
RUN cd assets && npm install --dev --force
# Build our application
RUN cd assets && npm run build
# Our production Vuejs app. We will serve it from nginx directly as they are all static.
FROM nginx
# Listen on port 80
EXPOSE 80
# As you can notice, we just reuse the nginx.conf file we used before with a small change.
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# copy all build artificats from build-node. Notice the --from
COPY --from=build-node /app/assets/dist/ /usr/share/nginx/html/
The only change in shopmanagementclient/nginx/nginx.conf file is as follows. We will host our phoenix app in docker-compose with service name web. That is why we change the name of server from localhost to web. You will see the docker-compose.yml file soon.
server {
# All /api/v1 calls will be redirected to Phoenix
location /api/v1/ {
# As we run docker compose, we use name of the our phoenix service, ie web
proxy_pass http://web:4000/api/v1/;
}
Now the only step left is to combine all build docker containers with docker-compose. The contents of docker-compose.yml file in shopmanagementdeploy is as follows.
version: '3'
services:
# Service web
web:
# Build file for server is in that folder
build: ../shopmanagementserver/
# This service needs postgres service to be up.
depends_on:
- postgres
# Service client
client:
# Build file for client is in that folder
build: ../shopmanagementclient/
# Expose container port 80 to host port 80
ports:
- "80:80"
depends_on:
# For this service to work, servie web should be up.
- web
# Database service
postgres:
image: postgres
volumes:
# Mount ./data fro host to /var/lib/postgresql/data in container
- ./data:/var/lib/postgresql/data
# Environment variables to be passed to postgres
environment:
POSTGRES_DB: ms_prod
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
# Map 5432 port from container to port 5434 in host
ports:
- "5434:5432"
Start docker compose with Nginx and Phoenix app
# Builds all containers
docker-compose build
# Starts docker-compose
docker-compose up
Now our server should be available at localhost. Let’s try to add a new product. And going to products page, we can confirm everything works fine.
Thats all folks. In next part we will make our vueJS client a bit more user friendly.