Simple Rails deployment using CDK

Apr 13, 2021

Introduction

In this post I'm going to create a very simple Ruby on Rails application and deploy it using AWS CDK. This has been done in a few places but I have a few difference goals here:

  • Create something that can be deployed as cheaply as possible. I really want to just be able to create a quick rails app and get it on the web, somewhat like Heroku, but using AWS, more for bootstrapping an MVP or something as quickly as possible.
  • I don't care about High Availability, redundancy etc. If things break, we'll just redeploy.
  • To learn more about CDK and few AWS features I haven't played with much at work, like SSM.

Setup

Create a directory called cdk-blog or whatever you want to be the base directory of the application and deploy code. The structure I'll use will look like:

cdk-blog 
  - blog # the actual application we're deploying
  - deploy # the cdk deploy code

For a great introduction to CDK see: https://dev.to/emmanuelnk/series/11689

Create the Rails application

$ rails new blog
      create
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
      create  .gitattributes

...

├─ websocket-extensions@0.1.4
└─ ws@6.2.1
✨  Done in 8.17s.
Webpacker successfully installed 🎉 🍰

Now lets get this thing to run on docker. If you're looking for a great guide on 'Dockerizing' Rails, check out this post by Evil Martian. I'm not using exactly what the Evil Martians created, but this works for me for the purposes of this post:

ARG BASE_IMAGE_VERSION=2.7

FROM ruby:$BASE_IMAGE_VERSION-alpine as builder

ARG BUNDLE_WITHOUT
ARG RAILS_ENV

ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
ENV RAILS_ENV ${RAILS_ENV}
ENV SECRET_KEY_BASE=foobar
ENV RAILS_SERVE_STATIC_FILES=true

RUN apk add --update --no-cache \
    build-base \
    postgresql-dev \
    git \
    nodejs \
    yarn \
    tzdata

WORKDIR /app

# Install gems
RUN gem install bundler
ADD Gemfile* /app/

RUN bundle install -j4 --retry 3 \
 # Remove unneeded files (cached *.gem, *.o, *.c)
 && rm -rf /usr/local/bundle/cache/*.gem \
 && find /usr/local/bundle/gems/ -name "*.c" -delete \
 && find /usr/local/bundle/gems/ -name "*.o" -delete

# Add the Rails app
ADD . /app

# Precompile assets
# RUN bundle exec rake assets:precompile

###############################
# Stage final
FROM ruby:$BASE_IMAGE_VERSION-alpine
MAINTAINER brendan.grainger@gmail.com

ARG ADDITIONAL_PACKAGES

# Add Alpine packages
RUN apk add --update --no-cache \
    postgresql-client \
    $ADDITIONAL_PACKAGES \
    tzdata \
    file

# Add user
RUN addgroup -g 1000 -S app \
 && adduser -u 1000 -S app -G app
USER app

# Copy app with gems from former build stage
COPY --from=builder /usr/local/bundle/ /usr/local/bundle/
COPY --from=builder --chown=app:app /app /app

# Set Rails env
ENV RAILS_LOG_TO_STDOUT true
ENV RAILS_SERVE_STATIC_FILES true

WORKDIR /app

# Expose Puma port
EXPOSE 3000

# Save timestamp of image building
RUN date -u > BUILD_TIME

I like to make sure it's all running ok locally. Create docker-compose.yml file and add the following:

# See: https://codetales.io/blog/publishing-ports-the-right-way
#
version: '3.7'
services:
  db:
    image: postgres:11-alpine
    environment:
      - POSTGRES_PASSWORD=password
    volumes:
      - db-data:/var/lib/postgresql/data
    ports:
      - 127.0.0.1:${PG_PORT}:5432
  app:
    #user: root
    image: rainkinz/blog-app:latest
    build:
      context: .
      args:
        # Install dev and test gems
        - BUNDLE_WITHOUT=production
    tty: true # do we need this?
    stdin_open: true # do we need this
    volumes:
      - type: bind
        source: ./
        target: /app
        consistency: cached
    environment:
      - RAILS_ENV
      - DB_USER=postgres
      - DB_PASSWORD=password
      - POSTGRES_PASSWORD=password
      - DB_HOST=db
      - SECRET_KEY_BASE=we-dont-need-a-secret-here
    ports:
      - 127.0.0.1:${APP_PORT:-3000}:3000
    links:
      - db
    command: ["rails", "server", "-b", "0.0.0.0", "-p", "${MY_APP_PORT:-3000}"]
    #
volumes:
  db-data:
$ docker-compose up
WARNING: The PG_PORT variable is not set. Defaulting to a blank string.
Creating network "blog_default" with the default driver
Creating volume "blog_db-data" with default driver
Pulling db (postgres:11-alpine)...
11-alpine: Pulling from library/postgres
ca3cd42a7c95: Pull complete
a0d003399a5b: Pull complete
0a08de1ad3ba: Pull complete
d339911efe38: Extracting [>                                                  ]  557.1kB/57.72MB
223f635ad1dd: Download complete

Creating the deploy

I am using AWS CDK to deploy the application.

First up install CDK:

$ npm install -g aws-cdk

Now create the application in our cdk-rails base directory:

$ mkdir deploy && cd deploy
$ cdk init --language=typescript 
# Welcome to your CDK TypeScript project!

This is a blank project for TypeScript development with CDK.

The `cdk.json` file tells the CDK Toolkit how to execute your app.

...

This will create a 'blank' Typescript based CDK application, with the following level folder structure (ignoring node_modules):

.
├── README.md
├── bin
│   └── deploy.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── deploy-stack.ts
├── node_modules
├── package-lock.json
├── package.json
├── test
│   └── tmp.test.ts
└── tsconfig.json

For not the 2 files we'll work with mostly are:

bin/deploy.ts

This contains the deployment 'glue' code.

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { DeployStack } from '../lib/deploy-stack';

const app = new cdk.App();
new DeployStack(app, 'DeployStack', {
  /* If you don't specify 'env', this stack will be environment-agnostic.
  * Account/Region-dependent features and context lookups will not work,
  * but a single synthesized template can be deployed anywhere. */

  /* Uncomment the next line to specialize this stack for the AWS Account
  * and Region that are implied by the current CLI configuration. */
  // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

  /* Uncomment the next line if you know exactly what Account and Region you
  * want to deploy the stack to. */
  // env: { account: '123456789012', region: 'us-east-1' },

  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});

lib/deploy-stack.ts

Which is where we'll write most of the deployment code for now. We will refactor later though. It looks like this:

import * as cdk from '@aws-cdk/core';

export class DeployStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here
  }
}

https://docs.aws.amazon.com/cdk/api/latest/docs/aws-ec2-readme.html#configuring-instances-using-cloudformation-init-cfn-init

Install some dependencies we'll need:

npm install --save @aws-cdk/aws-iam @aws-cdk/aws-ecr-assets
AWS_PROFILE=kuripai_dev cdk deploy --all 

Notice, I always explicitly use a profile or set it using .envrc . I prefer to do this than accidentally deploy to production or something...

References