From e2892e0aa98411890bc52ec8692138ece9ab5c5d Mon Sep 17 00:00:00 2001 From: Caleb LeNoir Date: Thu, 12 Mar 2026 09:00:18 -0400 Subject: [PATCH 1/3] Add Docker support with build and run scripts Create production-grade Dockerfile with multi-stage build, asset precompilation, and non-root user. Add bin/docker-build script to tag images with git SHA and bin/docker-run script to run with Traefik reverse proxy for local development at draft.localhost. Update README with Docker usage instructions covering basic runs and integration with Traefik for multi-project local dev setup. Co-Authored-By: Claude Haiku 4.5 --- Dockerfile | 72 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 64 ++++++++++++++++++++++++++++++++++++++++-- bin/docker-build | 23 ++++++++++++++++ bin/docker-run | 48 ++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100755 bin/docker-build create mode 100755 bin/docker-run diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d9c8b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# Match Ruby version from .tool-versions +ARG RUBY_VERSION=3.3.8 +FROM ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + curl \ + libjemalloc2 \ + sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" + + +# Build stage +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential \ + libffi-dev \ + libyaml-dev \ + libsqlite3-dev \ + git && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompile assets for production +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + +# Final production image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run as non-root user +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R 1000:1000 db log storage tmp +USER 1000:1000 + +# Entrypoint prepares the database +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server on port 3000 +EXPOSE 3000 +CMD ["./bin/rails", "server", "-b", "0.0.0.0"] diff --git a/README.md b/README.md index 9c999f7..1759f69 100644 --- a/README.md +++ b/README.md @@ -26,5 +26,65 @@ You can easily set up and run this project in a [VS Code Devcontainer](https://c - The app will be available at [http://localhost:8080](http://localhost:8080) on your host machine. ## Up and Running -* the devcontainer.json configuration will also copy your ssh settings for github to the docker container so you can -use git without needing to reauthenticate \ No newline at end of file +* the devcontainer.json configuration will also copy your ssh settings for github to the docker container so you can +use git without needing to reauthenticate + +## Running with Docker + +### Build the image + +```sh +bin/docker-build +``` + +Or pass a custom tag: + +```sh +bin/docker-build v1.0.0 +``` + +This tags the image as both `draft:` and `draft:`. + +### Run the container + +```sh +docker run -p 3000:3000 -v draft-db:/rails/db -v draft-storage:/rails/storage draft:latest +``` + +The app will be available at [http://localhost:3000](http://localhost:3000). The `-v` flags create named volumes so your database and uploaded files persist across container restarts. + +### Running at draft.localhost with Traefik + +If you want to run draft alongside other local dev servers without port conflicts, use a shared [Traefik](https://traefik.io/) reverse proxy. + +**1. One-time setup — start a shared Traefik instance:** + +```sh +docker network create proxy + +docker run -d \ + --name traefik \ + --network proxy \ + -p 80:80 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + traefik:v3.0 \ + --providers.docker \ + --providers.docker.exposedByDefault=false \ + --entrypoints.web.address=:80 +``` + +**2. Run draft with Traefik labels:** + +```sh +docker run -d \ + --name draft \ + --network proxy \ + -v draft-db:/rails/db \ + -v draft-storage:/rails/storage \ + -l traefik.enable=true \ + -l 'traefik.http.routers.draft.rule=Host(`draft.localhost`)' \ + -l traefik.http.services.draft.loadbalancer.server.port=3000 \ + draft:latest +``` + +The app will be available at [http://draft.localhost](http://draft.localhost). Other projects can use the same Traefik instance with their own `*.localhost` hostnames. \ No newline at end of file diff --git a/bin/docker-build b/bin/docker-build new file mode 100755 index 0000000..e08e81f --- /dev/null +++ b/bin/docker-build @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME="draft" +TAG="${1:-latest}" + +# Use git SHA as additional tag when available +GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + +echo "Building ${IMAGE_NAME}:${TAG} (git: ${GIT_SHA})..." + +docker build \ + -t "${IMAGE_NAME}:${TAG}" \ + -t "${IMAGE_NAME}:${GIT_SHA}" \ + . + +echo "" +echo "Built images:" +echo " ${IMAGE_NAME}:${TAG}" +echo " ${IMAGE_NAME}:${GIT_SHA}" +echo "" +echo "Run with:" +echo " docker run -p 3000:3000 ${IMAGE_NAME}:${TAG}" diff --git a/bin/docker-run b/bin/docker-run new file mode 100755 index 0000000..a2c58f2 --- /dev/null +++ b/bin/docker-run @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME="draft" +TAG="${1:-latest}" +CONTAINER_NAME="draft" +NETWORK="proxy" +HOSTNAME="draft.localhost" + +# Ensure the proxy network exists +if ! docker network inspect "$NETWORK" &>/dev/null; then + echo "Creating docker network '${NETWORK}'..." + docker network create "$NETWORK" +fi + +# Ensure Traefik is running +if ! docker ps --format '{{.Names}}' | grep -q '^traefik$'; then + echo "Starting Traefik..." + docker run -d \ + --name traefik \ + --network "$NETWORK" \ + -p 80:80 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + traefik:v3.0 \ + --providers.docker \ + --providers.docker.exposedByDefault=false \ + --entrypoints.web.address=:80 +fi + +# Remove existing container if stopped +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "Removing existing '${CONTAINER_NAME}' container..." + docker rm -f "$CONTAINER_NAME" +fi + +echo "Starting ${IMAGE_NAME}:${TAG} at http://${HOSTNAME}..." + +docker run -d \ + --name "$CONTAINER_NAME" \ + --network "$NETWORK" \ + -v draft-db:/rails/db \ + -v draft-storage:/rails/storage \ + -l traefik.enable=true \ + -l "traefik.http.routers.draft.rule=Host(\`${HOSTNAME}\`)" \ + -l traefik.http.services.draft.loadbalancer.server.port=3000 \ + "${IMAGE_NAME}:${TAG}" + +echo "Running at http://${HOSTNAME}" From 75064b91933eaad05f993b12ca2b836fdef64d95 Mon Sep 17 00:00:00 2001 From: Caleb LeNoir Date: Thu, 12 Mar 2026 09:39:02 -0400 Subject: [PATCH 2/3] Fix Docker setup for local development - Store SQLite DB in /rails/data via DATABASE_URL to avoid volume mount overwriting db/ directory (migrations, schema, seeds) - Fix docker-entrypoint to check first two args instead of last two, so db:prepare runs when CMD has extra flags like -b 0.0.0.0 - Make SSL configurable via env vars for local non-HTTPS usage - Simplify bin/docker-run to map ports directly instead of Traefik - Use local production.key for RAILS_MASTER_KEY - Update README with streamlined Docker instructions Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 3 ++- README.md | 38 ++++------------------------- bin/docker-entrypoint | 2 +- bin/docker-run | 40 ++++++++----------------------- config/environments/production.rb | 16 ++++++------- 5 files changed, 25 insertions(+), 74 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d9c8b9..7851b56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,7 +61,8 @@ COPY --from=build /rails /rails # Run as non-root user RUN groupadd --system --gid 1000 rails && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ - chown -R 1000:1000 db log storage tmp + mkdir -p data && \ + chown -R 1000:1000 db log storage tmp data USER 1000:1000 # Entrypoint prepares the database diff --git a/README.md b/README.md index 1759f69..1e08e05 100644 --- a/README.md +++ b/README.md @@ -48,43 +48,13 @@ This tags the image as both `draft:` and `draft:`. ### Run the container ```sh -docker run -p 3000:3000 -v draft-db:/rails/db -v draft-storage:/rails/storage draft:latest +bin/docker-run ``` -The app will be available at [http://localhost:3000](http://localhost:3000). The `-v` flags create named volumes so your database and uploaded files persist across container restarts. - -### Running at draft.localhost with Traefik - -If you want to run draft alongside other local dev servers without port conflicts, use a shared [Traefik](https://traefik.io/) reverse proxy. - -**1. One-time setup — start a shared Traefik instance:** - -```sh -docker network create proxy - -docker run -d \ - --name traefik \ - --network proxy \ - -p 80:80 \ - -v /var/run/docker.sock:/var/run/docker.sock \ - traefik:v3.0 \ - --providers.docker \ - --providers.docker.exposedByDefault=false \ - --entrypoints.web.address=:80 -``` - -**2. Run draft with Traefik labels:** +The app will be available at [http://draft.localhost:3000](http://draft.localhost:3000). To use a different port: ```sh -docker run -d \ - --name draft \ - --network proxy \ - -v draft-db:/rails/db \ - -v draft-storage:/rails/storage \ - -l traefik.enable=true \ - -l 'traefik.http.routers.draft.rule=Host(`draft.localhost`)' \ - -l traefik.http.services.draft.loadbalancer.server.port=3000 \ - draft:latest +PORT=3001 bin/docker-run ``` -The app will be available at [http://draft.localhost](http://draft.localhost). Other projects can use the same Traefik instance with their own `*.localhost` hostnames. \ No newline at end of file +Named volumes (`draft-data` and `draft-storage`) persist the database and uploaded files across container restarts. \ No newline at end of file diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 57567d6..3fa5c58 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -7,7 +7,7 @@ if [ -z "${LD_PRELOAD+x}" ]; then fi # If running the rails server then create or migrate existing database -if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then +if [ "$1" == "./bin/rails" ] && [ "$2" == "server" ]; then ./bin/rails db:prepare fi diff --git a/bin/docker-run b/bin/docker-run index a2c58f2..2bcf0a7 100755 --- a/bin/docker-run +++ b/bin/docker-run @@ -4,45 +4,25 @@ set -euo pipefail IMAGE_NAME="draft" TAG="${1:-latest}" CONTAINER_NAME="draft" -NETWORK="proxy" -HOSTNAME="draft.localhost" +PORT="${PORT:-3000}" -# Ensure the proxy network exists -if ! docker network inspect "$NETWORK" &>/dev/null; then - echo "Creating docker network '${NETWORK}'..." - docker network create "$NETWORK" -fi - -# Ensure Traefik is running -if ! docker ps --format '{{.Names}}' | grep -q '^traefik$'; then - echo "Starting Traefik..." - docker run -d \ - --name traefik \ - --network "$NETWORK" \ - -p 80:80 \ - -v /var/run/docker.sock:/var/run/docker.sock \ - traefik:v3.0 \ - --providers.docker \ - --providers.docker.exposedByDefault=false \ - --entrypoints.web.address=:80 -fi - -# Remove existing container if stopped +# Remove existing container if present if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then echo "Removing existing '${CONTAINER_NAME}' container..." docker rm -f "$CONTAINER_NAME" fi -echo "Starting ${IMAGE_NAME}:${TAG} at http://${HOSTNAME}..." +echo "Starting ${IMAGE_NAME}:${TAG} on port ${PORT}..." docker run -d \ --name "$CONTAINER_NAME" \ - --network "$NETWORK" \ - -v draft-db:/rails/db \ + -p "${PORT}:3000" \ + -e RAILS_ASSUME_SSL=false \ + -e RAILS_FORCE_SSL=false \ + -e RAILS_MASTER_KEY="${RAILS_MASTER_KEY:-$(cat config/credentials/production.key)}" \ + -e DATABASE_URL="sqlite3:/rails/data/production.sqlite3" \ + -v draft-data:/rails/data \ -v draft-storage:/rails/storage \ - -l traefik.enable=true \ - -l "traefik.http.routers.draft.rule=Host(\`${HOSTNAME}\`)" \ - -l traefik.http.services.draft.loadbalancer.server.port=3000 \ "${IMAGE_NAME}:${TAG}" -echo "Running at http://${HOSTNAME}" +echo "Running at http://draft.localhost:${PORT}" diff --git a/config/environments/production.rb b/config/environments/production.rb index 36daef5..6073c39 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -25,10 +25,10 @@ config.active_storage.service = :local # Assume all access to the app is happening through a SSL-terminating reverse proxy. - config.assume_ssl = true + config.assume_ssl = ENV.fetch('RAILS_ASSUME_SSL', 'true') == 'true' # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + config.force_ssl = ENV.fetch('RAILS_FORCE_SSL', 'true') == 'true' # Skip http-to-https redirect for the default health check endpoint. # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } @@ -62,12 +62,12 @@ # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. config.action_mailer.smtp_settings = { - user_name: Rails.application.credentials.mailer[:username], - password: Rails.application.credentials.mailer[:password], - address: Rails.application.credentials.mailer[:address], - domain: Rails.application.credentials.mailer[:domain], - port: Rails.application.credentials.mailer[:port], - authentication: Rails.application.credentials.mailer[:authentication], + user_name: Rails.application.credentials.dig(:mailer,:username), + password: Rails.application.credentials.dig(:mailer,:password), + address: Rails.application.credentials.dig(:mailer,:address), + domain: Rails.application.credentials.dig(:mailer,:domain), + port: Rails.application.credentials.dig(:mailer,:port), + authentication: Rails.application.credentials.dig(:mailer,:authentication), enable_starttls_auto: true } From dd34d8a1d652fd8a3ac9cb3182b72b691866ae53 Mon Sep 17 00:00:00 2001 From: Caleb LeNoir Date: Thu, 12 Mar 2026 09:42:52 -0400 Subject: [PATCH 3/3] Default to port 80 and handle MailerLite failures gracefully - Run Docker container on port 80 so draft.localhost works without specifying a port - Rescue MailerLite errors during signup to prevent 500s when the API key is missing or invalid - Move sqlite3 gem out of dev/test group so it's available in production - Add production.key to .gitignore - Use dig for credential access to avoid NoMethodError on nil Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 ++ Gemfile | 7 +------ README.md | 2 +- app/models/concerns/mailing_list.rb | 6 +++++- bin/docker-run | 8 ++++++-- config/credentials/production.yml.enc | 1 + config/initializers/mailerlite.rb | 2 +- 7 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 config/credentials/production.yml.enc diff --git a/.gitignore b/.gitignore index e0c3069..166cccc 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ coverage /app/assets/builds/* !/app/assets/builds/.keep + +/config/credentials/production.key diff --git a/Gemfile b/Gemfile index 6225e78..7537a58 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem 'redis', '~> 5.0' gem 'solid_queue', '~> 1.1' gem 'sprockets-rails' gem 'stimulus-rails', '~> 1.3' +gem 'sqlite3' gem 'turbo-rails', '~> 1.5' # Use Active Storage variant @@ -35,8 +36,6 @@ gem 'net-pop', require: false gem 'net-smtp', require: false group :development, :test do - # Use sqlite3 as the database for Active Record - gem 'sqlite3' # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: %i[mri mingw x64_mingw] gem 'htmlbeautifier' @@ -66,9 +65,5 @@ group :test do gem 'simplecov', require: false end -group :production do - # gem 'pg', '~> 1.5' -end - # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/README.md b/README.md index 1e08e05..c2d54f6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ This tags the image as both `draft:` and `draft:`. bin/docker-run ``` -The app will be available at [http://draft.localhost:3000](http://draft.localhost:3000). To use a different port: +The app will be available at [http://draft.localhost](http://draft.localhost). To use a different port: ```sh PORT=3001 bin/docker-run diff --git a/app/models/concerns/mailing_list.rb b/app/models/concerns/mailing_list.rb index 5521a4e..57a5479 100644 --- a/app/models/concerns/mailing_list.rb +++ b/app/models/concerns/mailing_list.rb @@ -2,7 +2,11 @@ module MailingList MAILER_LITE_GROUP = 111712173989824242 def subscribe_to_mailing - MailerLite.create_group_subscriber(MAILER_LITE_GROUP, { email: email }) if Rails.env.production? + return unless Rails.env.production? + + MailerLite.create_group_subscriber(MAILER_LITE_GROUP, { email: email }) + rescue StandardError => e + Rails.logger.warn("MailerLite subscription failed for #{email}: #{e.message}") end end \ No newline at end of file diff --git a/bin/docker-run b/bin/docker-run index 2bcf0a7..d8c57fa 100755 --- a/bin/docker-run +++ b/bin/docker-run @@ -4,7 +4,7 @@ set -euo pipefail IMAGE_NAME="draft" TAG="${1:-latest}" CONTAINER_NAME="draft" -PORT="${PORT:-3000}" +PORT="${PORT:-80}" # Remove existing container if present if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then @@ -25,4 +25,8 @@ docker run -d \ -v draft-storage:/rails/storage \ "${IMAGE_NAME}:${TAG}" -echo "Running at http://draft.localhost:${PORT}" +if [ "$PORT" = "80" ]; then + echo "Running at http://draft.localhost" +else + echo "Running at http://draft.localhost:${PORT}" +fi diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc new file mode 100644 index 0000000..2dd44b2 --- /dev/null +++ b/config/credentials/production.yml.enc @@ -0,0 +1 @@ +GIPj3kbP/xHIbHWy5G6bSAR0PMzVe30hJsRgdxOvjcTIQEuVPO8uEhyvTVANlO/1RzDVVnKJtxnZRO0ty/Z1N/KYLB6VhmKQcKr0+g8bY/EneEcfyRj3iarEg1MHf860IOA4aF5snBIQw4Qs229jFWwVZ7h+CDkIhoVeBXz75buXu23VXZmn26q4iv8sxlehAqDfUyb8po2CWf0sXFLg2N5T/Mk6hrDDMb9rJYu3Ut3VlGom5/LFAhCjTuxIudUf6LMjoM5pIKJvi66vU9vwBBQzbASxgMxYMaaTaJWgbkyvgvs9hWY3NvXk0t6dIbdsRKqQNT+Ou2c6cbscRFhJLL/AfRlvceiwwHItwakrhgn79z2i4X8/I7mTu8OyYQ6pgOgfXQRNmcnV4Ef+V+lcOFHLl/9C3/lV7+md/CplK1y9nG/k9JID9e2h7f8EaZl74Th3gL5YEC41XnPtpVcU32Y4t09/Ya6HiOpfG2OiEpWawaX2N1gMdxlt--drWRJWyzzwUZdrhF--b/iTIrkCtZAy0iE1tCYB3A== \ No newline at end of file diff --git a/config/initializers/mailerlite.rb b/config/initializers/mailerlite.rb index 8a9d123..cd293f2 100644 --- a/config/initializers/mailerlite.rb +++ b/config/initializers/mailerlite.rb @@ -1,4 +1,4 @@ MailerLite.configure do |config| - config.api_key = Rails.application.credentials.mailerlite[:api_key] if Rails.env.production? + config.api_key = Rails.application.credentials.dig(:mailerlite,:api_key) if Rails.env.production? # config.timeout = 10 end