Injecting SSH Keys into Rails Docker Image at Build Time

Dockerization at 10,000 Feet

Let's start with a bit of terminology. Recall that the containerization tool Docker builds objects called "containers" and "images". An image is a read-only template containing a unix file system snapshot that is built on and mutated in "layers". A container is a runnable instance of an image.

Many developers and project creators often discuss "Dockerizing" their applications. What this means is that they create a static file called "Dockerfile" at the root of the project that describes how to build a Docker image of their project. For example:

# tree my_app
.
├── Dockerfile
├── app.rb
├── controllers
│   ├── comments.rb
│   └── posts.rb
├── models
│   ├── comment.rb
│   └── post.rb
└── public
    ├── index.html
    ├── main.css
    └── main.js

This Dockerfile is consumed by the Docker daemon and contains special instructions and steps  describing in Dockerfile syntax how to build this new Docker image. This includes: installing dependencies, copying application code, injecting environment variables and configurations. But how is a Docker image built? Glad you asked.

Docker images all begin their humble life with a base image (e.g. FROM ubuntu or FROM scratch) containing a snapshot of a (usually) empty unix file system. This base image is then modified by running commands, adding, removing, or changing existing file system artifacts using Dockerfile to produce new intermediate image layers. After all modifications are complete, the final image layer (filesystem snapshot) is what is used as the Docker image for your application. If we look at an example of a Go application Dockerfile:

FROM golang:1.14-alpine

ENV GO111MODULE=on \
    CGO_ENABLED=0 \
    GOOS=linux \
    GOARCH=amd64

# Set the destination for current operations to the directory on the 
# image file system called "app" 
WORKDIR /app

# Copy application code from local file system to image filesystem
COPY . .

# Build the application
RUN go build -o main .

# Export necessary port
EXPOSE 3000

# Command to run when starting the container
CMD ["/app/main"]

With the review of Docker let's begin composing our Dockerfile for a Rails instance. We have three tasks to undertake in constructing our Docker image:

  1. Installing application container dependencies
  2. Adding an SSH key into the image
  3. Copying over the required application code so Docker can run it in a container.

Adding the Base Image and Installing Dependencies

First create a new file in the root of your Rails directory called, you guessed it, Dockerfile. Next add the following set of Dockerfile commands which we will be explained step by step:

# Dockerfile
FROM ruby:2.7.0-alpine ❶

# Update the installer
RUN apk update -qq && apk upgrade ❷

The FROM command in a Dockerfile defines the base image that the new Docker image will be generated from. This image can be full blown Linux distribution like Ubuntu or more economical (in terms of hard disk space) base images that just bundles your language of choice. We choose the more economical approach with the ruby:2.7.0-alpine ❶ image. The -alpine suffix for a Docker image means that the base image is an  Alpine Linux distribution. Alpine Linux is a popular, lightweight, security oriented Linux distribution that a many Docker community use. These containers tend to smaller and conserve hard disk space when stored on a host.

Next we update ❷ the Alpine package management utility (apk) using the Dockerfile RUN command which allows us to execute arbitrary code in context of the image. Upon updating the package manger we begin installing a variety of system dependencies our application will require.

# Dockerfile

# --- snipped --- 

# Install deps
RUN apk add --no-cache \
	 # Necesssary ❸
	 build-base \
	 postgresql-dev \
	 postgresql-client \
	 nodejs yarn tzdata \
	 # Nice to have for deployments ❹
	 openssh \
	 # Nice to have for working with containers ❺
	 vim bash

The first dependency installed is build-base ❸. This installs essential dependencies like gcc, g++,  libstdc++, make, etc that are required by other packages. The next dependency is  postgresql-dev  and postgresql-client to provide the app PostgreSQL binaries for development and client purposes. In addition,   nodejs and yarn are installed for working with the static assets in a Rails application. Finally, tzdata (Time Zone Data) which is necessary for the tzinfo-data gem in the Gemfile.

After we install the basic dependencies for our container to function we add some additional application dependencies. OpenSSH ❹ is installed to give us tools like ssh, ssh-keyscan, ssh-add, etc, that will be used by our container to SSH into a server.  Finally, some niceties when working with containers are installed: vim for editing files in the container's file system and bash to provide a more flexible shell ❺.

Adding SSH Keys to the Docker Image

The next Dockerfile step is adding the actual SSH key to the image. The approach we will take is using a Docker build argument to inject an SSH key into the image at build time. Docker provides strong containerization guarantees including process and file system isolation, meaning the SSH key in your local file system is not be accessible from inside the application container. Instead, we will copy the SSH key from our system into the container image.

Note: Checking an SSH key into a Docker image has security implications especially if you plan to host this image publicly. If you add your key to the image file system, any person who accesses your Docker image will have access to your keys.  There are better ways to accomplish adding an SSH key into the container that do not involve bundling it into the image.
# Dockerfile

# --- snipped --- 

ARG SSH_KEY ❶
ARG HOST
RUN mkdir ~/.ssh/

RUN echo "$SSH_KEY" > ~/.ssh/id_rsa ❷
RUN chmod 600 ~/.ssh/id_rsa
RUN touch ~/.ssh/known_hosts ❸
RUN ssh-keyscan -t rsa $HOST > ~/.ssh/known_hosts ❹

# Set environment variables from build arguments ❺
ENV HOST=$HOST 

Using the Dockerfile ARG instruction several build arguments are defined SSH_KEY, HOST ❶. These arguments are passed to the Dockerfile during the build process via the --build-arg option. The SSH_KEY contains the RSA private key, HOST is the domain name where a server lives. The build argument SSH_KEY uses the default SSH key path ~/.ssh/id_rsa ❷ and adds user read/write permissions with the octal permission code 600 ❸. Next the HOST to the list of known_hosts ❹. Finally,  container environment variables are set up as the input build arguments in the image ❺.

Note: the step ❹ has security implications. By directly adding the URL to the container's known_hosts , it will no longer check the authenticity of the host key opening the application up to a man-in-the-middle attack! A better approach involves checking the key fingerprint.

Adding Application Code to the Docker Image

To wrap up the Dockerfile creation we have one more step to copy the application code from our working directory into the image's file system. Let's see how to do this below.

# Dockerfile

# --- snipped ---

RUN gem install bundler --version '~> 2.1.0' ❶

# Create that directory in the base image
WORKDIR /usr/src/app ❷

# Copy gemfile before 
COPY Gemfile* ./ ❸
RUN bundle install ❹

COPY . . ❺
RUN yarn install --check-files ❻

# App
EXPOSE 3000 ❼

First the bundler gem ❶ is installed on the image. Next the WORKDIR command is used to declare the current working directory in the image as /usr/src/app ❷ (this is a common convention). After moving to the new working directory the Gemfile and Gemfile.lock are copied over ❸ to the image via COPY and gem dependencies are installed using bundle install ❹. This is a subtle but important step that  improves the image build performance because Docker caches the results of the updated image file system with each step. This means if application code is changed but the Gemfile remain the same, Docker can fetch the previous cached image containing the gem installation and avoid having to reinstall (which takes a long time).

Once the  gems are installed, the application code from your projects local file system is copied over to the image using the COPY command again ❺. With the files copied over, the build process installs JavaScript dependencies using yarn install command ❻. The image now contains all the necessary application code to run, so the final step is to expose port 3000 for of the container for the application to run on ❼. The Dockerfile is complete!

Creating Docker Compose

version: "3"
services:
  app: ❶
    build: 
      context: . ❷
      dockerfile: Dockerfile
    command: './entrypoint.sh' ❸
    volumes:
      - .:/app 
    env_file: .env.docker ❹
    depends_on: ❺
      - redis

# ---- snipped -- 

In the compose file a new app service is created ❶ and instructs Docker to build the image from the Dockerfile in the relative file path ❷. The entrypoint command is a file called entrypoint.sh ❸ which we will create in a moment. Next Docker is instructed to inject environment variable configurations  from a file called .env.docker  ❹ . Let's create the entrypoint.sh in the root of the project directory:

#!/bin/sh

# Start ssh agent
eval `ssh-agent` ❶
echo "SSH Agent is Running..."

bundle exec rails server

There are just a few important house keeping items setup in this file. The first is to start the ssh-agent ❶ in the container. To use the ssh system commands in the application container to connect to the remove server, you will need to run the ssh-agent. Be sure to add executable permissions to entrypoint.sh or the container will fail to start up!

Making a Makefile

At this point you can build a shiny new container to run. However, to make your life easier you can create a "Makefile" containing the  steps to automate the build process.

# Makefile 

# Makefile variables to inject
KEY=`cat ~/.ssh/id_rsa` ❶
HOST='yourhost.com'
APP_NAME="app" ❷

build: ❸
 @docker-compose -p "$(APP_NAME)" build \ ❹
	 --build-arg HOST=$(HOST) \ 
	 --build-arg SSH_KEY="${KEY}" ❺

The Makefile declares variables that are used as Docker build arguments in the image building stage: KEY, HOST. While most variables are strings, note the use of shell commands when defining the KEY variable ❶. The KEY variable uses the output of the cat shell program reading a local file from system then hardcoding the key. This is done to avoid plain text private key in the Makefile.  APP_NAME ❸ is not a build argument but instead the project name we will use for our Docker containers.

The first Makefile recipe is build ❸. Using docker-compose.yml  the target calls  docker-compose build and provide it the project name (-p switch) to use for our container images ❹. The  build function scans  docker-compose.yml  and determines what images it needs to build and starts the image building process.

Part of the image building process involves passing--build-arg values. Note that we use Bash parameter expansion (${}) ❺ instead of command substitution ($()) for the SSH_KEY variable. This is to ensure that whitespaces and formatting are preserved in the key contents when passed as build arguments.

The last targets in our Makefile are start, stop and clean. The start ❶ target starts up our containers, while stop ❷ tears them down. Finally the clean ❸ target also stops any running containers and remove any local images and orphans that may have been created in the process.

start: ❶
 @docker-compose -p "$(APP_NAME)" up 

stop: ❷
 @docker-compose -p "$(APP_NAME)" down

clean: ❸
 @docker-compose -p "$(APP_NAME)" down -v --rmi local --remove-orphans

With the birth of your new images the moment has arrived to test your might (!!) by running make start to see if you have gained sufficient strength to karate chop the metaphorical wooden blocks of containerization.

make start

If your training has rewarded you with running containers then give yourself a pat on the back.