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
- 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.
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.
docker run elixirdocker runcreates and starts a new container from an image.elixiruses the latest official Docker elixir image.--rmSince
docker runcreates a new container just to run this single command, this option will delete the container once it has finished. Using--rmkeeps us from creating a bunch of containers that have to be cleaned up later.-v $PWD:/optWe 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/optdirectory on the container filesystem. Any changes made in the container to the/optdirectory will be reflected on our filesystem.-w /optThis option sets the directory that the command will run in. Since we mounted our project files in the container’s
/optdirectory, 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
rootand be uneditable to us without usingsudo.mix new static_siteThe 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.
$ 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:
FROM elixir:slimThis is the image we are basing ours on. The
slimversion is slightly smaller, so we chose it to save space.WORKDIR /optJust like the
-woption in ourdocker runcommand, this sets the working directory to/opt.COPY lib ./libCOPY mix.exs ./mix.exsThese copy our project files into the current working directory (
/opt).ARG MIX_ENV=devMakes the
MIX_ENVvariable available in theDockerfileif it is set in the environment. If it is not set, this tells Docker to usedevas the default value.RUN mix deps.get --only $MIX_ENV && mix buildFetches 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.
$ 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
- 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.
- We log in to DigitalOcean, go to our Apps page and click “Create App”.
- On the “Create Resource From Source Code” screen, we click on “Edit Your GitHub Permissions”.
- We do what GitHub calls “installing” the DigitalOcean app on our GitHub account.
- 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.
- We select “GitHub” as the “Service Provider”. Our repo should now appear in the list. We select it and click “Next”.
- 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.
- Under “Resource Type”, we click “Edit” and select “Static Site”. Then we click “Save”.
- We edit the “Output Directory” and set it to
/public, the location of the static files in the container. Then click “< Back”. - Under the “App” section, we should see “Starter Plan” and “Static Site”. We click “Next”.
- On the “Environment Variables” page, we can set
MIX_ENV, the variable our Dockerfile will need during the build process, toprod. It probably works the same whether set under “Global” or local to the app, but we just set it on thestatic-siteapp. - 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.
- 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.