Configuring Docker for Crystal Development

Aug 31, 2018 Last updated Aug 31, 2018

How to set up a Docker container for easy and quick Crystal development

Building Out the Crystal Project

Use the built-in Crystal command for building out a project boilerplate:

crystal init app super_app

It should go without saying that you can replace super_app with your app name if you're some rebelious kewl kid.

Now replace the main file contents in the src/ directory with your choice of framework like Raze, Kemal, Amber, or use the built in Crystal HTTP server to get started:

require "http/server"

server = HTTP::Server.new do |context|
  context.response.content_type = "text/plain"
  context.response.print "Hello world, got #{context.request.path}!"
end

puts "Listening on http://127.0.0.1:7777"

server.listen("0.0.0.0", 7777)

"0.0.0.0" is required so the service is accessble to docker

Make sure this runs by running crystal src/super_app.cr and visiting http://localhost:7777 in your browser.

Getting a Functioning Docker File

Let do the minimum to get this app running in Docker. Create a docker file at the root fo your project called super_app.dockerfile with the following contents:

FROM crystallang/crystal:0.26.0
WORKDIR /app

# install shards
COPY shard.yml ./shard.yml
RUN shards install

# server
COPY src ./src

# compile app
RUN crystal build --release src/super_app.cr

# run app
CMD ./super_app

Build the a docker image from the file by doing:

docker build -f super_app.dockerfile -t super_app:latest .

Run the image as a container by doing:

docker run --name super_app -p 7777:7777 --rm -it super_app:latest

Before rerunning the container, you may need to remove it by running docker rm super_app

Make sure its running by visiting http://localhost:7777 in your browser.

Using "Docker Compose" for Better Image/Container Management

So far we would need to rebuild and rerun the app using individual docker command, which gets tedious. Let's implement docker-compose so things are cleaner as we develop.

Add a docker-compose.super_app.yml file with the following contents:

version: "3"
services:
  super_app:
    volumes:
      - "./src:/app/src"
    build:
      context: ./
      dockerfile: super_app.dockerfile
    networks:
      - super_app
    ports:
      - "7777:7777"
    container_name: super_app
    command: './super_app'

networks:
  super_app:
    driver: bridge

Run the app via docker-compose by doing:

docker-compose -f docker-compose.super_app.yml up --build

Again, before rerunning the container, you may need to remove it by running docker rm super_app

Your app should be running on http://localhost:7777.

Automatically Compiling on Changes Without Rebuilding Docker Image

The Dockerfile and docker-compose.yml configurations work so far but for any code changes we have to stop the running process, rebuild the image, and run the conatiner.

Something like Nodemon for Node.js, which watches our files and re-runs the app on changes, would come in handy here. Sentry would be the most popular Crystal equivalent to Nodemon. Sentry will run your Crystal app for you, watches your source files, and recompiles then reruns your application on any changes. In fact, it was actually created because of how painful it is to develop a Crystal app in Docker (I would know).

Install it from the root of your app:

curl -fsSLo- \
  https://raw.githubusercontent.com/samueleaton/sentry/master/install.cr | \
  crystal eval

The Sentry code is installed in a /dev folder so we need to make sure that folder is added to our Docker image from our dockerfile and make sure Sentry is compiled before running it (2 lines added):

FROM crystallang/crystal:0.26.0
WORKDIR /app

# install shards
COPY shard.yml ./shard.yml
RUN shards install

COPY dev ./dev
COPY src ./src

# compile app
RUN crystal build --release dev/sentry_cli.cr -o ./sentry
# Don't need to compile the app anymore directly if using Sentry
# RUN crystal build --release src/super_app.cr

# run app
CMD env CRYSTAL_ENV=production ./super_app

Now to run the app with Sentry we will override the default command in the docker file from the docker-compose file:

version: "3"
services:
  super_app:
    volumes:
      - "./src:/app/src"
    build:
      context: ./
      dockerfile: super_app.dockerfile
    networks:
      - super_app
    ports:
      - "7777:7777"
    container_name: super_app
    command: './sentry --install'

networks:
  super_app:
    driver: bridge

The --install flag will make sure shards are installed before running Sentry.

Run the app again using docker-compose:

docker-compose -f docker-compose.super_app.yml up --build

Seeing Sentry Do Its Job

While Sentry is running, add the following line to the top of src/super_app.cr:

puts "Hey! I'm using Sentry!"

You should see Sentry recompile and start your app, like this:

super_app    | 🤖  ./src/super_app.cr
super_app    | 🤖  compiling super_app...
super_app    | 🤖  killing super_app...
super_app    | 🤖  starting super_app...
super_app    | Hey! I'm using Sentry!
super_app    | Listening on http://127.0.0.1:7777

That's all. Now you don't need to worry about rebuilding your app on any changes and you can develop your apps without compromising your human dignity.