Web Dev Solutions

Catalin Mititiuc

Web Log

Elixir, JavaScript, SVG, Containers, Git, Linux

Questions, comments, feedback? Contact the author.

Deploy Elixir-Generated HTML With Docker On DigitalOcean

Introduction

DigitalOcean has this App Platform service that can host a static website, as well as build it from a Docker image, if provided a Dockerfile. We thought a static website built by an Elixir app could be an instructive project. To explore if the idea is viable, we wrote a small Elixir application that generates a simple index.html file and deployed it live on DigitalOcean’s service.

Requirements

This is not an endorsement of these vendors. We don’t have special affinity for any of them. We just like containers, git, and PaaS. This post is specific to DigitalOcean, though, because that’s where we deploy our site.

Procedure

The instructions are divided into four parts.

  1. Create the mix project
  2. Create the build task
  3. Add the Dockerfile
  4. Deploy live

We’re going to do this whole thing with containers. That means Docker is all we need to have installed on our machines. Be warned, though, that also means we’ll be running Elixir through the docker command.

1. Create the mix project

We use the mix new command to create our Elixir project. If we were using Elixir installed natively on our computer, it would just be the last part, mix new static_site. Since we are using Docker, the command looks like this:

$ docker run --rm -w /opt -v $PWD:/opt -u $(id -u):$(id -u) elixir mix new static_site

That might look a bit overwhelming, so let’s explain each part of the command.

After the command finishes, we have a new directory on our filesystem called static_site. Let’s change into that directory and make sure we can run the tests. We don’t care if the files mix creates in _build are owned by root, so we don’t bother setting the user and group with the -u option when we run the command this time.

$ cd static_site
$ docker run --rm -w /opt -v $PWD:/opt elixir mix test

We should see a successful test result.

With our Mix project files generated, we move on to implementing creating our static HTML file.

2. Create the build task

Because our output will only contain static markup, our Elixir application will not be a long running process in production. It will only run once, during the build phase of deployment. A one-off job is the perfect role for a Mix task. The Task module documentation shows an example file to start with and even tells us where to put it (lib/mix/tasks).

We name the task simply build and create a file called build.ex. It uses Elixir’s File module to first create a directory called /public. Then, it writes a minimal index.html file at that location.

lib/mix/tasks/build.ex

defmodule Mix.Tasks.Build do
  @moduledoc "Creates a /public directory and places an HTML index file there"
  @shortdoc "Builds static HTML file"

  use Mix.Task

  @output_directory "/public"

  @markup """
    <!DOCTYPE html>
    <html>
      <head></head>
      <body>hello world</body>
    </html>
  """

  @impl Mix.Task
  def run(_args) do
    Mix.shell().info("running build task")

    File.mkdir_p!(@output_directory)
    @output_directory |> Path.join("index.html") |> File.write!(@markup)
  end
end

The easiest way to test our task is to run mix build and then inspect the contents of /public/index.html with the cat command, but docker run only accepts a single command. We can combine both into one with bash -c "mix build && cat /public/index.html".

$ docker run --rm -w /opt -v $PWD:/opt elixir bash -c "mix build && cat /public/index.html"

If all went well, our output should be:

running build task
  <!DOCTYPE html>
  <html>
    <head></head>
    <body>hello world</body>
  </html>

With creating the Mix task complete, it is time to add a Dockerfile to our project.

3. Add the Dockerfile

We could commit HTML files to our repo directly and deploy them that way. It would not require Docker at all. But if we want to use Elixir to generate hypertext markup programatically, we will have to add a Dockerfile for building our project in production.

Dependencies

In the last section, we built the HTML file by calling our Mix task with the command, mix build. Let’s make sure our build handles dependencies by adding one to the project. We add a plug dependency in the mix.exs file and try building again.

mix.exs

defmodule StaticSite.MixProject do
  use Mix.Project
  ...

  defp deps do
    [
      {:plug, ">= 0.0.0"}
    ]
  end
end

We will also have to add a call to mix deps.get in our command. Again, we combine the two commands into one with &&:

$ docker run --rm -w /opt -v $PWD:/opt elixir bash -c "mix deps.get && mix build"
* creating /root/.mix/archives/hex-2.0.6
Resolving Hex dependencies...
...
* Getting plug (Hex package)
...
Compiling 2 files (.ex)
Generated static_site app
running build task

We should see hex installing, dependencies being fetched, the project compiled, and our mix task being ran.

Environments

There is a problem with this method, however. What if the dependencies are only needed during development? We don’t want dev environment dependencies being included when we deploy to production. To test this, change {:plug, ">= 0.0.0"} to {:plug, ">= 0.0.0", only: :dev} in mix.exs.

Development (dev)

We will re-run our command, but we will add a call to mix deps to see what dependencies our project builds.

$ docker run --rm -w /opt -v $PWD:/opt elixir bash -c "mix deps.get && mix build && mix deps"
...
Compiling 2 files (.ex)
Generated static_site app
running build task
* mime 2.0.5 (Hex package) (mix)
  locked at 2.0.5 (mime) da0d64a3
  ok
* plug 1.15.2 (Hex package) (mix)
  locked at 1.15.2 (plug) 02731fa0
  ok
* plug_crypto 2.0.0 (Hex package) (mix)
  locked at 2.0.0 (plug_crypto) 53695bae
  ok
* telemetry 1.2.1 (Hex package) (rebar3)
  locked at 1.2.1 (telemetry) dad9ce9d
  ok

Our dev environment has the dependency we added.

Production (prod)

Now, we will run the command again after we set the environment variable MIX_ENV to prod. We do this with the -e option:

$ docker run --rm -w /opt -v $PWD:/opt -e MIX_ENV=prod elixir bash -c "mix deps.get && mix build && mix deps"
* creating /root/.mix/archives/hex-2.0.6
Resolving Hex dependencies...
...
* Getting plug (Hex package)
* Getting mime (Hex package)
* Getting plug_crypto (Hex package)
* Getting telemetry (Hex package)
...
Compiling 2 files (.ex)
Generated static_site app
running build task

mix deps doesn’t list any dependencies in the prod environment, which is what we want. However, we don’t want it getting any dependencies that aren’t used, either.

Using the --only option

It turns out that mix deps.get has an --only option that can be used to fetch dependencies only for a specific environment. We try our command again with that option.

$ docker run --rm -w /opt -v $PWD:/opt -e MIX_ENV=prod elixir bash -c "mix deps.get --only prod && mix build && mix deps"
* creating /root/.mix/archives/hex-2.0.6
All dependencies are up to date
Compiling 2 files (.ex)
Generated static_site app
running build task

We don’t see any dependencies being fetched, so that works as we want it to. We can set MIX_ENV to prod in production and use mix deps.get --only $MIX_ENV in our Dockerfile to fetch dependencies only belonging to that environment.

Writing the file

Dockerfiles have 2 different ways to use variables, ENV and ARG. ENV variables will be available in the running container, while ARG variables are only available during the image build process. Since we only need the variable to be available during the deployment, we need to use ARG in our Dockerfile. We can even set a default, so that we don’t need to set MIX_ENV explicitly when we are developing. Here is our complete Dockerfile:

Dockerfile

FROM elixir:slim

WORKDIR /opt
COPY lib ./lib
COPY mix.exs ./mix.exs

ARG MIX_ENV=dev

RUN mix deps.get --only $MIX_ENV && mix build

Here’s the explanation:

With that complete, we can now build an image.

Building the image

dev environment

This first build will not have the MIX_ENV variable set, so we expect it to default to dev and to find the plug dependency installed.

$ docker build -t hw_dev .

Let’s see what dependencies our image contains:

$ docker run --rm hw_dev mix deps

* mime 2.0.5 (Hex package) (mix)
  locked at 2.0.5 (mime) da0d64a3
  ok
* plug 1.15.2 (Hex package) (mix)
  locked at 1.15.2 (plug) 02731fa0
  ok
* plug_crypto 2.0.0 (Hex package) (mix)
  locked at 2.0.0 (plug_crypto) 53695bae
  ok
* telemetry 1.2.1 (Hex package) (rebar3)
  locked at 1.2.1 (telemetry) dad9ce9d
  ok

That looks good. And let’s also check that our HTML file was written:

$ docker run --rm hw_dev ls /public
index.html
prod environment

Excellent. Now let’s build a production image. We pass in the value to set for the MIX_ENV argument with the --build-arg option:

$ docker build -t hw_prod --build-arg MIX_ENV=prod .

And now we check as before. We have to set MIX_ENV in the container if we want our mix command to run in that environment. We do this with the -e option.

$ docker run --rm -e MIX_ENV=prod hw_prod mix deps
$

This shows no dependencies, as expected. And check our index.html:

$ docker run --rm hw_prod ls /public
index.html

It works! Now we can deploy.

4. Deploy live

  1. First, we push our git repo to GitHub. It doesn’t have to be a public repo, we will give DigitalOcean access to it in a moment.
  2. We log in to DigitalOcean, go to our Apps page and click “Create App”.
  3. On the “Create Resource From Source Code” screen, we click on “Edit Your GitHub Permissions”.
  4. We do what GitHub calls “installing” the DigitalOcean app on our GitHub account.
  5. We add the repo to the “Repository access” list and click “Save”. We should be redirected back to the DigitalOcean “Create Resource From Source Code” screen.
  6. We select “GitHub” as the “Service Provider”. Our repo should now appear in the list. We select it and click “Next”.
  7. It detected our Dockerfile and thinks we want to run a web service. We need to edit the resource to tell it it’s a static site that just needs Docker to build. We click on the “Edit” button.
  8. Under “Resource Type”, we click “Edit” and select “Static Site”. Then we click “Save”.
  9. We edit the “Output Directory” and set it to /public, the location of the static files in the container. Then click “< Back”.
  10. Under the “App” section, we should see “Starter Plan” and “Static Site”. We click “Next”.
  11. On the “Environment Variables” page, we can set MIX_ENV, the variable our Dockerfile will need during the build process, to prod. It probably works the same whether set under “Global” or local to the app, but we just set it on the static-site app.
  12. We don’t have a reason to change anything on the “Info” screen, so we click “Next”. If we wanted to change the resource name, this would be the time to do it.
  13. At the bottom of the “Review” screen, we click “Create Resources”.

After deployment finishes successfully, we should be able to visit the link that DigitalOcean gives us and we will be greeted by our “hello world” HTML page.

Making changes

If we want to make changes, we can commit our updates and push the code to GitHub. This will trigger a rebuild and deploy the changes, automatically.

Conclusion

Our Elixir-generated HTML file is live and hosted. We have completed our proof of concept. If we wanted to take advantage of DigitalOcean’s platform and host an Elixir-generated static website, this is the blueprint we could follow. It’s relatively simple if familiar with Docker, and once set up, deploying changes with a git push is simply magical. We look forward to using what we have learned in a future project.