Dockerizing our Haskell App
Last week, we explored how to automate the deployment of our Haskell app with Circle CI. Every time we push a branch, Circle CI will load our code onto a container, build it, and run any tests we have. We also configured Heroku to deploy our new code whenever the master branch passed the build.
Our system had a couple weaknesses though. First, it was a bit of a hassle that our configuration required us to download the Stack program every time. Setting up Stack required about half the commands in our Circle config! The second weakness was that we built our code twice on each deploy. First, the Circle container would build it. Then Heroku would also compile it. This week, we’ll solve these problems using Docker images.
Using Docker Images
Last week we used a vanilla Circle container. We can start simplifying our configuration by using a pre-existing Docker image instead. Remember the start of our build_project
section? It looked like this:
jobs:
build_project:
machine: true
The machine
keyword tells Circle to use an unconfigured Linux box. Since it had nothing on it, we needed to download and install Stack ourselves. However, Circle also allows us to use Docker images as the starting point for our machines. We’ll use an image from the Haskell Docker repository. These each have a particular version of GHC installed, and the later ones also come with Stack. These images lag behind GHC releases a little bit. So we’ll use GHC 8.0.2, and update our stack.yaml
file to use LTS 9.21, the latest version for this GHC. Here’s how we write our Circle configuration to use this image:
jobs:
build_project:
docker:
- image: haskell:8.0.2
Now we can radically simplify the rest of the file! Stack and GHC will be pre-installed, so we can remove all the steps related to those. We’ll also remove the caching step on the installed Stack directory. This leaves us with the following configuration file:
version: 2
jobs:
build_project:
docker:
- image: haskell:8.0.2
steps:
- checkout
- restore-cache:
keys:
- stack-work-{{checksum “stack.yaml”}}-\
{{checksum “HaskellTestApp.cabal”}}
- run: stack setup
- run: stack build
- run: stack test
- save_cache:
key: stack-work-{{checksum “stack.yaml”}}-\
{{checksum “HaskellTestApp.cabal”}}
paths:
- “.stack-work”
workflows:
version: 2
build_and_test:
jobs:
- build_project
Making Our Own Docker Image
Now our builds are a little more efficient, but we haven’t solved the bigger problem in our system. In the rest of this article, we’ll use Docker to create a new image with our code built on it. Then we can push this image to Heroku instead of re-building our code with the buildpack.
To do this, we’ll fold some of the existing Circle configuration work into Docker itself. To start, we need to define a Dockerfile
at the root of our project. This file specifies the commands Docker needs to run to create an image with our code and run the server. Here’s what ours looks like:
# Use the existing Haskell image as our base
FROM haskell:8.0.2
# Checkout our code onto the Docker container
WORKDIR /app
ADD . /app
# Build and test our code, then install the “run-server” executable
RUN stack setup
RUN stack build --test --copy-bins
# Expose a port to run our application
EXPOSE 80
# Run the server command
CMD [“run-server”]
The first important part is that we’ll “inherit” from the Haskell Docker image we were using on Circle with FROM
. Then we’ll run our setup command, and build the project. We’ll pass arguments to build
that will run the tests, and install our executables. Then, we’ll run the server off the container.
Build our Docker Image on Circle
To actually save a Docker image on a remote repository, we’ll need to make a Docker account. We don’t need to create our own repository, since we’ll end up storing our image on a Heroku repository.
We no longer need to run Stack commands as part of our Circle configuration. Docker handles them for us. We can go back to using a normal machine, as Docker also handles using the Haskell image. Here’s the core of our configuration on Circle:
jobs:
build_project:
machine: true
steps:
- checkout
- run: echo $DOCKER_PASSWORD | docker login \
--username=$DOCKER_USERNAME \
--password-stdin
- run: docker pull \
registry.heroku.com/$HEROKU_APP/web:$CIRCLE_BRANCH
- run: docker build -t \
registry.heroku.com/$HEROKU_APP/web:$CIRCLE_BRANCH
- run: docker push \
registry.heroku.com/$HEROKU_APP/web:$CIRCLE_BRANCH
The key commands are obviously the four docker
commands. First, we log into our Docker account using our credentials as environment variables. Next, we’ll pull the existing image off the Heroku image repository tied to our app. We don’t need to do anything to set this repository up, but we’ll need to configure the app to use it below. Then we build our container and tag it with our current branch name. As long as this succeeds, we’ll push this new image back to our Docker repository.
Heroku Integration
To use this image on Heroku, we’ll need to update the “Deploy” section of our app again from the dashboard. Instead of using Circle CI, we’ll use the Heroku registry option. Now our successful builds will push our code up to our Heroku registry. Then Heroku updates our app automatically! Plus, there will now be no need for us to rebuild the code on Heroku!
There’s one more caveat though. To push and pull from the Heroku registry, we also need to login to Heroku from our circle machine. Circle CI version 2 doesn’t yet have built-in support for this, so it’s a little tricky. On our own machine, we would login to Heroku using the CLI with the heroku login
command. But we can’t use that command with stdin
the way we can with Docker’s login command.
But we can replicate the ultimate result of logging in with a little script. Logging into Heroku creates a file called ~/.netrc
storing our credentials. We can write this script that will output all that information like so:
#! /bin/bash
cat > ~/.netrc << EOF
machine api.heroku.com
login $HEROKU_LOGIN
password $HEROKU_PASSWORD
machine git.heroku.com
login $HEROKU_LOGIN
password $HEROKU_PASSWORD
EOF
heroku container:login
We run the final heroku container
command to actually connect to the repository. Note that the $HEROKU_PASSWORD
environment variable should use your Heroku API Key, NOT your Heroku password. We call the variable PASSWORD
because the HEROKU_API_KEY
environment variable is special. It can cause problems with the CLI to have it set pre-maturely.
With this script saved as setup_heroku.sh
, we can call it from our Circle script like so:
jobs:
build_project:
machine: true
steps:
- checkout
- bash .circleci/setup_heroku.sh
...
Now everything should work! Our app should be automatically deployed to Heroku without re-compilation!
Conclusion
We’ve now made our deployment process a lot more efficient. First we used a Docker Haskell image to avoid manually downloading Stack. Then we created our own Docker image off of this, and pushed it to a registry. Once we connected our Heroku app to this registry, we no longer needed to compile it there. Next week, we’ll conclude this series by using a similar process to push our code to AWS instead of Heroku.
Now that you can deploy your code, you can make whatever Haskell apps you want! Download our Production Checklist to get some more ideas for libraries you can use in your apps.
And if you’ve never used Haskell before, download our Beginners Checklist to get started!