Creating a Local Docker Image
Running a web server locally is easy. Deploying it so other people can use your web application can be challenging. This is especially true with Haskell, since a lot of deployment platforms don't support Haskell natively (unlike say, Python or Javascript). In the past, I've used Heroku for deploying Haskell applications. In fact, in my Practical Haskell and Effectful Haskell courses I walk through how to launch a basic Haskell application on Heroku.
Unfortunately, Heroku recently took away its free tier, so I've been looking for other platforms that could potentially fill this gap for small projects. The starting point for a lot of alternatives though, is to use Docker containers. Generally speaking, Docker makes it easy to package your code into a container image that you can deploy in many different places.
So today, we're going to explore the basics of packing a simple Haskell application into such a container. As a note, this is different from building our project with stack using Docker. That's a subject for a different time. My next few articles will focus on eventually publishing and deploying our work.
Starting the Dockerfile
So for this article, we're going to assume we've already got a basic web server application that builds and runs locally on port 8080. The key step in enabling us to package this application for deployment with Docker is a Dockerfile
.
The Dockerfile specifies how to set up the environment in which our code will operate. It can include instructions for downloading any dependencies (e.g. Stack or GHC), building our code from source, and running the necessary executable. Dockerfiles have a procedural format, where most of the functions have analogues to commands we would run on a terminal.
Doing all the setup work from scratch would be a little exhausting and error-prone. So the first step is often that we want to "inherit" from a container that someone else has published using the FROM
command. In our case, we want to base our container off of one of the containers in the Official Haskell repository. We'll use one for GHC 9.2.5. So here is the first line we'll put in our Dockerfile:
FROM haskell:9.2.5
Building the Code
Now we have to actually copy our code into the container and build it. We use the COPY
command to copy everything from our project root (.
) into the absolute path /app
of the Docker container. Then we set this /app
directory as our working directory with the WORKDIR
command.
FROM haskell:9.2.5
COPY . /app
WORKDIR /app
Now we'll build our code. To run setup commands, we simply use the RUN
descriptor followed by the command we want. We'll use 2-3 commands to build our Haskell code. First we use stack setup
to download GHC onto the container, and then we build the dependencies for our code. Finally, we use the normal stack build
command to build the source code for the application.
FROM haskell:9.2.5
...
RUN stack setup && stack build --only-dependencies
RUN stack build
Running the Application
We're almost done with the Dockerfile! We just need a couple more commands. First, since we are running a web server, we want to expose the port the server runs on. We do this with the EXPOSE
command.
FROM haskell:9.2.5
...
EXPOSE 8080
Finally, we want to specify the command to run the server itself. Supposing our project's cabal file specifies the executable quiz-server
, our normal command would be stack exec quiz-server
. You might expect we would accomplish this with RUN stack exec quiz-server
. However, we actually want to use CMD
instead of RUN
:
FROM haskell:9.2.5
...
CMD stack exec quiz-server
If we were to use RUN
, then the command would be run while building the docker container. Since the command is a web server that listens indefinitely, this means the build step will never complete, and we'll never get our image! However, by using CMD
, this command will happen when we run the container, not when we build the container.
Here's our final Dockerfile (which we have to save as "Dockerfile" in our project root directory):
FROM haskell:9.2.5
COPY . /app
WORKDIR /app
RUN stack setup && stack build --only-dependencies
RUN stack build
EXPOSE 8080
CMD stack exec quiz-server
Creating the Image
Once we have finished our Dockerfile, we still need to build it to create the image we can deploy elsewhere. To do this, you need to make sure you have Docker installed on your local system. Then you can use the docker build
command to create a local image.
>> docker build -t quiz-server .
You can then see the image you created with the docker images
command!
>> docker images
REPOSITORY TAG IMAGE ID ...
quiz-server latest abcdef123456 ...
If you want, you can then run your application locally with the docker run
command! The key thing with a web server is that you have to use the -p
argument to make sure that the exposed ports on the docker container are then re-exposed on your local machine. It's possible to use a different port locally, but for our purposes, we'll just use 8080
for both like so:
>> docker run -it -p 8080:8080 --rm quiz-server
Conclusion
This creates a local docker image for us. But this isn't enough to run the program anywhere on the web! Next time we'll upload this image to a service to deploy our application to the internet!
If you want to keep up with this series, make sure to subscribe to our monthly newsletter!