I recently helped Sidecar Learning move their legacy application built with React front-end and Rails back-end migrate from Heroku and AWS to VPS over at DigitalOcean.

This guide is outcome of that migration. React front-end was quite simple and do not have much moving parts so it’s quite simple to do this. If you are looking to deploy monolith application then you can read the following posts I have written:

Kamal Config

First, let’s create a simple deploy.yml for Kamal. The configuration is mostly boilerplate. I am using GHA cache to speed up the deploys.

# config/deploy.yml

service: react-app
image: username/image-name

servers:
  web:
    - 123.456.789.012

registry:
  username: docker_username
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:
    FOO: BAR
    NPM_CONFIG_PRODUCTION: false

builder:
  multiarch: false
  cache:
    type: gha
    options: mode=max

If you wish to, you can use “registry” cache as well. I have noticed that it’s faster by 5-10 seconds but if you are using free Docker Hub account then you only get one free image. That’s why I prefer to use “gha” as cache back-end. Kamal only supports “gha” and “registry”. If you would like to use the “registry” cache, use the following builder config:

builder:
  multiarch: false
  cache:
    type: registry
    options: mode=max,image-manifest=true,oci-mediatypes=true

Dockerfile

Our app is using quite old version of NodeJS. This is why I like Kamal more than anything. No platform dependency or requirement to fulfil. You create your own environment and Kamal provides thin wrapper around the Docker commands. As simple as it could be.

Here, we first create a build step. Once the build is ready, no need to include all those node_modules in our final application. We keep it as small as possible so that it’s easier to perform operations.

In our app, we have a small Express app that serves the static file. That’s why I have created a separate package.runtime.json file. You can modify that part as per your own need. The next session will explain how the express server works.

# Dockerfile

FROM node:11.10.1 as build

WORKDIR /app

ENV NODE_ENV="production" \
    NPM_CONFIG_PRODUCTION="false"

COPY . .

RUN npm ci

RUN npm run build

FROM node:11.10.1

WORKDIR /app

ENV NODE_ENV="production" \
    NPM_CONFIG_PRODUCTION="false"

COPY --from=build /app/build ./build
COPY --from=build /app/server.js ./server.js
COPY --from=build /app/package.runtime.json ./package.json

RUN npm install --production

EXPOSE 3000

CMD ["node", "server.js"]

Express Server

We have a tiny express server that serves the static files with appropriate headers. We have configured Cloudflare to provide SSL support and cache the static assets. Only index.html file is not cached, and bundled assets will be served by this Express server only once. Then Cloudflare will take the load. All of this is deployed on our $4 DigitalOcean droplet and it is handling our moderately busy app very well.

I have configured the Webpack to generate bulid with hash, so with every new build, the new hash will rename the file - same as Propshaft.

I have added a route for Kamal healthcheck as well even though the health-check step has been removed in Kamal 1.6.0.

// server.js

const compression = require('compression');
const express = require('express');
const path = require('path');
const port = process.env.PORT || 3000;
const app = express();

// Use compression middleware
app.use(compression());

// Serve static files with cache control headers
app.use(express.static(path.join(__dirname, 'build'), {
  maxAge: '1d', // Cache for 1 day
  setHeaders: (res, path) => {
    // Set cache-control headers based on file types
    if (path.endsWith('.js')) {
      res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
    } else if (path.endsWith('.css')) {
      res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
    } else if (path.endsWith('.html')) {
      res.setHeader('Cache-Control', 'public, max-age=0'); // No cache
    } else {
      res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day
    }
  }
}));

// Kamal health check route
app.get('/up', (req, res) => {
  res.send(`<!DOCTYPE html><html><body style="background-color: green"></body></html>`);
});

// Send all requests to index.html to handle routing in React Router
app.get('*', (req, res) => {
  res.sendFile(path.resolve(__dirname, 'build', 'index.html'));
});

// Start the server
app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

We only need ExpressJS and the compression packages to run this small server. That’s why we are including only these two packages in the runtime package file.

package.runtime.json

{
  "name": "your-app",
  "version": "1.0.0",
  "main": "server.js",
  "dependencies": {
    "compression": "^1.7.4",
    "express": "^4.16.4"
  },
  "scripts": {
    "start": "node server.js"
  }
}

GitHub Action

It’s a headache to run the deployment from your own machine. The following GitHub Actions config will solve it for you. I have added a concurrency config too that will let only one deploy run at a time. It’s useful when you make multiple commits in a short time.

# .github/workflows/deploy.yml

name: Deploy to production

# To make sure that only one deploy runs at a time. Deploy lock will not let simultaneous deployments.
concurrency:
  group: ${{ github.workflow }}

on:
  push:
    branches: [master]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      KAMAL_REGISTRY_PASSWORD: $

    steps:
      - name: Set up Docker Buildx for cache
        uses: docker/setup-buildx-action@v3

      # Since we are using GHA cache, we need to expose the cache to the runtime
      - name: Expose GitHub Runtime for cache
        uses: crazy-max/ghaction-github-runtime@v3

      - name: Checkout code
        uses: actions/checkout@v3

      # Ruby is only needed to install Kamal
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'

      - name: Install dependencies
        run: gem install kamal

      # This is to facilitate Kamal with the private key to access server(s)
      - uses: webfactory/[email protected]
        with:
          ssh-private-key: $

      - name: Run deploy command
        run: kamal deploy