--- title: "Deploy Elixir-Generated HTML With Docker On DigitalOcean" blurb: "This is a simple proof of concept where we create a boilerplate HTML file with Elixir, containerize our build process with Docker, and deploy our markup live with DigitalOcean's hosting service." ... ## Introduction DigitalOcean has this [App Platform](https://www.digitalocean.com/products/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 * Docker * GitHub account * DigitalOcean account 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](#create-the-mix-project) 2. [Create the build task](#create-the-build-task) 3. [Add the `Dockerfile`](#add-the-dockerfile) 4. [Deploy live](#deploy-live) ::: warning 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: ```console $ 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. * `docker run elixir` `docker run` creates and starts a new container from an image. `elixir` uses the latest [official Docker elixir image](https://hub.docker.com/_/elixir). * `--rm` Since `docker run` creates a new container just to run this single command, this option will delete the container once it has finished. Using `--rm` keeps us from creating a bunch of containers that have to be cleaned up later. * `-v $PWD:/opt` We want the files Mix generates to be accessible to us so we can edit and keep them in version control. We need to bring them out of the container somehow. We do this by mount binding a volume. This option binds the result of `$PWD`, which is the current directory on our filesystem, to the `/opt` directory on the container filesystem. Any changes made in the container to the `/opt` directory will be reflected on our filesystem. * `-w /opt` This option sets the directory that the command will run in. Since we mounted our project files in the container's `/opt` directory, we want to set it as the working directory. * `-u $(id -u):$(id -u)` This option sets the container user and group to match our operating system's current user. If we don't do this, the files generated will all belong to `root` and be uneditable to us without using `sudo`. * `mix new static_site` The command we want to run in the container. 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. ```console $ 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](https://hexdocs.pm/mix/Mix.Task.html) 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. ::: filename-for-code-block `lib/mix/tasks/build.ex` ::: ```elixir 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 """ hello world """ @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"`. ```console $ 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: ```console running build task hello world ``` 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`

```elixir 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 `&&`: ```console $ 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. ```console $ 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: ```console $ 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](https://hexdocs.pm/mix/Mix.Tasks.Deps.Get.html) that can be used to fetch dependencies only for a specific environment. We try our command again with that option. ```console $ 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`

```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: * `FROM elixir:slim` This is the image we are basing ours on. The `slim` version is slightly smaller, so we chose it to save space. * `WORKDIR /opt` Just like the `-w` option in our `docker run` command, this sets the working directory to `/opt`. * `COPY lib ./lib` `COPY mix.exs ./mix.exs` These copy our project files into the current working directory (`/opt`). * `ARG MIX_ENV=dev` Makes the `MIX_ENV` variable available in the `Dockerfile` if it is set in the environment. If it is not set, this tells Docker to use `dev` as the default value. * `RUN mix deps.get --only $MIX_ENV && mix build` Fetches depencies for our mix project and runs our build task. 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. ```console $ docker build -t hw_dev . ``` Let's see what dependencies our image contains: ```console $ 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: ```console $ 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: ```console $ 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. ```console $ docker run --rm -e MIX_ENV=prod hw_prod mix deps $ ``` This shows no dependencies, as expected. And check our index.html: ```console $ 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](https://cloud.digitalocean.com/login) 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.