Deploying Phoenix VueJS application using Docker - Part 14


stutorial docker elixir phoenix

Other articles in the series

  • Making our UI user friendly - Part 15
  • Deploying Phoenix VueJS application using Docker - Part 14
  • Fixing failing Elixir tests - Part 13
  • Adding new features to Order Management - Part 12
  • Add Order Management - Part 11
  • Refactoring and adding tests for Phoenix - Part 10
  • Refactoring VueJS with Typescript- Part 9
  • Add Customer Management - Part 8
  • Writing tests with Jest and Typescript - Part 7
  • Adding Vuex to Vue using Typescript - Part 6
  • Building our Homepage view component - Part 5
  • Add Multi-language support to Vue Typescript - Part 4
  • Generate Vue Typescript Application - Part 3
  • Setting up Models with Ecto and Adding Routing . Part 2
  • Setting up Your Phoenix Application - Part 1
  • Tutorial Series for building a VueJS (Typescript) and Phoenix(Elixir) Shop Management Application - Part 0

  • 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.

    1. Rename config/prod.secret.exs to config/releases.exs
    2. Replace use Mix.Config in config/releases.exs to import Config.
    3. 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.