@schrockwell :: now :: dev :: hw :: retro :: misc

How I deploy web applications

I have a lot of solo side-projects. Many of them are web apps with very modest resource requirements. Managing their deployments isn’t fun. And it’s easy to forget how to do them in the months (or years) between releases.

So here’s my simple, standardized, reproducible, inexpensive playbook for easy deployments.


Hosting

I use DigitalOcean. Linode could work too.


Secrets

Secret environment variables like API keys are manged by SSE, a tool I wrote to fulfill this exact need.

In Development

direnv automatically loads the secrets into the shell.

# --- .envrc ---

#! /bin/bash
eval "$(sse load)"

In Production

The sse binary is included in the production Docker image. The entrypoint script loads the variables before starting the server.

# --- bin/entrypoint ---

#! /bin/bash
eval "$(sse load production)"
exec "$@"

Kamal

Kamal is the secret sauce for effortless deploys. There are a few conventions to follow to keep it painless for hosting many small projects on a single host.

Define a wildcard DNS entry

Add an A record for *.example.com directly to your VM.

Any new apps deployed the hostname [appname].example.com will “just work” with TLS when you deploy them for the first time, since Let’s Encrypt will be able to find the server without waiting for DNS to propagate. Once you confirm the deploy works, the hostname can be changed to its final value.

Build x64 images locally

Even on ARM devices like Apple Silicon, you can still build x64 images locally via buildx. Don’t try to run builds on your deployment box, since they can be resource-intensive and cause downtime for your live apps.

builder:
  arch: amd64

Use the local image registry

Kamal now ships with an image registry that runs right on your local machine’s Docker daemon. You don’t need to pay (or manage credentials) for a hosted solution. Just use the really powerful computer that’s right in your lap – it’s free.

registry:
  server: localhost:5555

SQLite

SQLite is great for little self-hosted projects. And it’s free!

Create directories on the host OS like /var/lib/kamal/myapp/data and mount them as volumes.

# --- config/deploy.yml ---

volumes:
  -/var/lib/kamal/myapp/data:/app/data

And in the app simply point SQLite to /app/data/myapp_prod.db.

File Permissions

To ensure the mounted directories have the correct permissions to be accessed by the container, chown nobody:nogroup on the host OS, and specify USER nobody in the Dockerfile.

Web Interface

To temporarily start a web-based web management UI for SQLite, here’s a shell script to spin up sqlite-web in Docker on port 8080 (open the port only to your IP).

# --- /var/lib/kamal/sqlite-web.sh ---

#! /bin/bash

DATABASE_PATHS=$(find . -type f \( -iname "*.sqlite" -o -iname "*.db" \) -printf '%P ')

echo "Started sqlite-web at http://example.com:8080/"

docker run -it --rm \
        -p 8080:8080 \
        -v /var/lib/kamal:/data \
        ghcr.io/coleifer/sqlite-web:latest \
        $DATABASE_PATHS

Miscellaneous

On macOS, use OrbStack instead of Docker Desktop. It’s way nicer, and also supports VMs.

Some Elixir dependencies have trouble in a cross-compiling environment. Here’s the fix, applied during the build phase:

# --- Dockerfile ---

# Disable BEAM JIT to avoid QEMU emulation bugs during cross-compilation
ENV ERL_FLAGS="+JPperf true"

Getting Started

// TODO: Write out full instructions for installing Kamal, setting up SSH, doing the first deploy, etc.


Example: Static Jekyll site

The amazing lipanski/docker-static-website image is perfect for serving up static content. It uses BusyBox httpd and is only 80 KB!

# --- Dockerfile ---

# --- Production stage ---
FROM ruby:3.3.5-alpine AS builder

RUN apk add --no-cache build-base

WORKDIR /site

COPY Gemfile Gemfile.lock ./
RUN bundle install

COPY . .
RUN bundle exec jekyll build

# --- Production stage ---
FROM lipanski/docker-static-website:latest

COPY --from=builder /site/_site/ .

Kamal config:

# --- config/deploy.yml ---

service: myapp

image: myname/myapp

servers:
  web:
    - kamal.example.com

proxy:
  ssl: true
  host: myapp.example.com
  app_port: 3000
  healthcheck:
    path: /

registry:
  server: localhost:5555

builder:
  arch: amd64

Jekyll config:

# --- _config.yml ---
exclude:
  - config/deploy.yml