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 elixir
docker run
creates and starts a new container from an image.elixir
uses the latest official Docker elixir image.--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 usingsudo
.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.
$ 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: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 ourdocker 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 theDockerfile
if it is set in the environment. If it is not set, this tells Docker to usedev
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.
$ 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-site
app. - 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.