From 7a515369dcc6069c06b835b2f4a65c182ea56e7a Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 10 Oct 2026 15:53:39 +0900 Subject: [PATCH 1/3] Deploy with Kamal 2 to ghcr.io instead of Render Generated apps will be deployed with Kamal to a VPS alongside the other apps in the fleet, so the template now carries that configuration and the Render config goes. Added - Dockerfile, .dockerignore and bin/docker-entrypoint from the Rails 8.1 defaults (Ruby from .ruby-version, non-root user, Thruster on port 80). .dockerignore also excludes vendor/bundle, which bin/setup and CI fill with host-platform gems. - config/deploy.yml: image pushed to ghcr.io, kamal-proxy with Let's Encrypt on app.domain, Postgres 18 as the "db" accessory reachable only over the Docker network (published on 127.0.0.1 for admin work), Solid Queue in Puma, a volume for Active Storage, arm64 builder. A commented block shows the Cloudflare Origin Certificate variant. - .kamal/secrets reading KAMAL_REGISTRY_PASSWORD and NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD from the environment and RAILS_MASTER_KEY from config/credentials/production.key. - kamal and thruster gems with bin/kamal and bin/thrust binstubs. - GET /up (rails/health#show). production.rb already exempted /up from host authorization and logging, but no route existed, so kamal-proxy's health check would have answered 404 and the deploy would never go live. assume_ssl is on so force_ssl does not redirect the proxy's plain-HTTP health check either. - test/config/deploy_config_test.rb keeps deploy.yml, .kamal/secrets, database.yml and the Dockerfile consistent (accessory role/database match database.yml, DB_HOST is "-db", every referenced secret is defined, loopback-only Postgres port, Ruby version, ...). test/integration/health_check_test.rb covers /up. Changed - database.yml production reads DB_HOST and POSTGRES_PASSWORD; the DATABASE_URL overrides still work through Rails' built-in handling. - config.hosts is app.domain from config/settings.yml plus APP_HOST. - README gets a Deployment section; AGENTS.md describes the Kamal setup. Removed - render.yaml, bin/render-*.sh and the RENDER_* environment handling. - The staging environment (config/environments/staging.rb, credentials/staging.yml.enc, db/fixtures/staging, the staging blocks in cable/cache/queue/recurring.yml, the staging branch in mail.rb and its test). Render PR previews were its only user. Verified by building the image and running it against a postgres:18 container laid out like the accessory: /up 200 (with and without proxy headers), sign-in with bad credentials 401 through the database, wrong Host 403, db:prepare created the four databases, Solid Queue supervisor/worker/dispatcher/scheduler started inside Puma with the recurring task registered, process runs as uid 1000. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WyUzNEPWyhjjWrBcSaXSW4 --- .dockerignore | 57 +++++++++ .gitignore | 1 - .kamal/secrets | 22 ++++ AGENTS.md | 14 +-- Dockerfile | 74 ++++++++++++ Gemfile | 6 + Gemfile.lock | 33 ++++++ README.md | 14 +++ bin/docker-entrypoint | 2 +- bin/kamal | 16 +++ bin/render-build.sh | 16 --- bin/render-seed.sh | 15 --- bin/render-start.sh | 19 --- bin/thrust | 16 +++ config/cable.yml | 8 -- config/cache.yml | 4 - config/credentials/staging.yml.enc | 1 - config/database.yml | 34 +----- config/deploy.yml | 111 ++++++++++++++++++ config/environments/production.rb | 16 +-- config/environments/staging.rb | 109 ----------------- config/initializers/mail.rb | 7 +- config/queue.yml | 3 - config/recurring.yml | 3 - config/routes.rb | 5 + db/fixtures/staging/01_permissions.rb | 9 -- db/fixtures/staging/02_roles.rb | 5 - db/fixtures/staging/03_roles_permissions.rb | 25 ---- db/fixtures/staging/10_app_versions.rb | 12 -- db/fixtures/staging/11_privacy_versions.rb | 11 -- db/fixtures/staging/12_terms_versions.rb | 11 -- ...nativeapptemplate-substrate-v2-overview.md | 2 +- docs/phase1-rails-api.md | 2 +- render.yaml | 66 ----------- test/config/deploy_config_test.rb | 87 ++++++++++++++ test/config/mailer_host_test.rb | 19 +-- test/config/recurring_tasks_test.rb | 12 +- test/integration/health_check_test.rb | 12 ++ 38 files changed, 490 insertions(+), 389 deletions(-) create mode 100644 .dockerignore create mode 100644 .kamal/secrets create mode 100644 Dockerfile create mode 100755 bin/kamal delete mode 100755 bin/render-build.sh delete mode 100755 bin/render-seed.sh delete mode 100755 bin/render-start.sh create mode 100755 bin/thrust delete mode 100644 config/credentials/staging.yml.enc create mode 100644 config/deploy.yml delete mode 100644 config/environments/staging.rb delete mode 100644 db/fixtures/staging/01_permissions.rb delete mode 100644 db/fixtures/staging/02_roles.rb delete mode 100644 db/fixtures/staging/03_roles_permissions.rb delete mode 100644 db/fixtures/staging/10_app_versions.rb delete mode 100644 db/fixtures/staging/11_privacy_versions.rb delete mode 100644 db/fixtures/staging/12_terms_versions.rb delete mode 100644 render.yaml create mode 100644 test/config/deploy_config_test.rb create mode 100644 test/integration/health_check_test.rb diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8da8bf6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore gems installed into the project (bin/setup / CI put them here). They are +# built for the host's platform, and the image runs bundle install itself. +/vendor/bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* + +# Ignore agent and editor files +/.claude +/docs-private +.DS_Store diff --git a/.gitignore b/.gitignore index bd32048..0503704 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,6 @@ # Ignore master key for decrypting credentials and more. /config/master.key -/config/credentials/staging.key /config/credentials/development.key /config/credentials/production.key diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000..8110f2f --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,22 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# GitHub Container Registry password: a Personal Access Token (classic) with the +# write:packages scope, for the user/org in config/deploy.yml's registry section. +# export KAMAL_REGISTRY_PASSWORD=ghp_xxxxxxxx +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Production credentials key. Decrypts config/credentials/production.yml.enc. +# Never check config/credentials/*.key into git (they're gitignored). +RAILS_MASTER_KEY=$(cat config/credentials/production.key) + +# Postgres password for the "db" accessory and the app's database connection. +# Read from an app-specific variable so it can't collide with another app's +# POSTGRES_PASSWORD in the same shell when several apps share one server. +# There is intentionally no fallback: a missing value fails the deploy instead +# of shipping an empty password. +# export NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD=$(openssl rand -hex 32) +# It is baked into the Postgres data volume on the FIRST deploy. Changing it +# later requires also altering the role inside Postgres. +POSTGRES_PASSWORD=$NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD diff --git a/AGENTS.md b/AGENTS.md index 5730e31..325ff9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ bin/rails dbconsole # Database console ### Background Processing - Solid Queue for background jobs (database-backed, no Redis needed) - Solid Cable for Action Cable (database-backed) -- Solid Cache for caching in production/staging +- Solid Cache for caching in production - Monitor jobs at `/madmin/jobs` (Mission Control) ### Push Notifications @@ -88,7 +88,7 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - **Device registration**: `POST /api/v1/shopkeeper/devices` is idempotent on `(platform, token)` — re-POST updates `last_active_at`; a token bound to another shopkeeper is reassigned. - **Notifiers**: subclass `ApplicationNotifier` (`Noticed::Event`). `ItemTagNotifier` fires from the AASM `complete` event. In `deliver_by :action_push_native`, the `with_apple`, `with_google`, and `with_data` options must each return a **Hash** (use `{}` when empty) — passing `nil` raises `TypeError: no implicit conversion of nil into Hash`. - **Config & secrets**: `config/push.yml` reads everything from credentials (`bin/rails credentials:edit --environment `) under `action_push_native:apns:{key_id, team_id, topic, encryption_key}` and `action_push_native:fcm:{project_id, encryption_key}`. APNs `encryption_key` is the full `.p8` PEM; FCM `encryption_key` is the entire service-account JSON. `topic` stays in credentials (not source) because the agent's rename pipeline would otherwise desync the bundle id. -- **APNs sandbox vs production**: `connect_to_development_server: <%= Rails.env.development? %>`. The token's environment is fixed at iOS build time — Xcode debug builds get **sandbox** tokens, TestFlight/App Store get **production**. They must match the server: test Xcode debug builds against a **local development** server; use TestFlight to test against Render. A mismatch returns APNs `400 BadDeviceToken`, which the gem treats as `TokenError` and **destroys the device row** (the default `rescue_from`). FCM has no such split. +- **APNs sandbox vs production**: `connect_to_development_server: <%= Rails.env.development? %>`. The token's environment is fixed at iOS build time — Xcode debug builds get **sandbox** tokens, TestFlight/App Store get **production**. They must match the server: test Xcode debug builds against a **local development** server; use TestFlight to test against the deployed server. A mismatch returns APNs `400 BadDeviceToken`, which the gem treats as `TokenError` and **destroys the device row** (the default `rescue_from`). FCM has no such split. ### Testing Strategy - Minitest for all tests (models, controllers, integration, policies) @@ -119,11 +119,11 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - Image processing with Active Storage and `image_processing` gem ### Deployment -- **This repo itself is never deployed.** It is the template that apps are generated from, so there is no production environment or production data. Don't weigh changes against deploy risk (migration downtime, existing records); do keep the Render config below correct, because generated apps inherit it. -- Configured for Render.com deployment (for generated apps) -- Build script: `bin/render-build.sh` -- Web server: `bin/render-start.sh` -- Solid Queue runs in Puma via `SOLID_QUEUE_IN_PUMA=true` +- **This repo itself is never deployed.** It is the template that apps are generated from, so there is no production environment or production data. Don't weigh changes against deploy risk (migration downtime, existing records); do keep the Kamal config below correct, because generated apps inherit it. +- Deployed with [Kamal 2](https://kamal-deploy.org) (`bin/kamal`): `Dockerfile` builds the image, `config/deploy.yml` describes the server, and `.kamal/secrets` reads secrets from the environment and `config/credentials/production.key`. Images go to GitHub Container Registry (`ghcr.io`). +- One server runs everything: the app (Puma behind Thruster on port 80, fronted by kamal-proxy with Let's Encrypt), and PostgreSQL as the `db` accessory on the private Docker network (`DB_HOST=-db`; never published to the internet). Solid Queue runs inside Puma via `SOLID_QUEUE_IN_PUMA=true`, so there is no separate job server. +- Everything named `nativeapptemplateapi` in `config/deploy.yml`, `.kamal/secrets` and `config/database.yml` is renamed together by the agent; the accessory's `POSTGRES_USER`/`POSTGRES_DB` must keep matching `database.yml`'s production `username`/`database` (`test/config/deploy_config_test.rb` checks this). +- The only environments are `development`, `test` and `production`. There is no staging: Render PR previews were the only thing that used it. ## Code Quality Checks Before Committing diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e66f25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t nativeapptemplateapi . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name nativeapptemplateapi nativeapptemplateapi + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.7 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages. libvips is for Active Storage variants; postgresql-client +# gives bin/rails dbconsole (and pg_dump) inside the container. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libvips libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock .ruby-version ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile index 43c6627..181ddb4 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,12 @@ gem "pg" # Use the Puma web server [https://github.com/puma/puma] gem "puma", "~> 8.0" +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] gem "turbo-rails", "~> 2.0.3" diff --git a/Gemfile.lock b/Gemfile.lock index 72f2536..7b4ff1a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,6 +94,7 @@ GEM ast (2.4.3) base64 (0.3.0) bcrypt (3.1.22) + bcrypt_pbkdf (1.1.2) better_html (2.2.0) actionview (>= 7.0) activesupport (>= 7.0) @@ -138,7 +139,9 @@ GEM bcrypt (~> 3.0) devise (> 3.5.2, < 6) rails (>= 4.2.0, < 8.3) + dotenv (3.2.0) drb (2.2.3) + ed25519 (1.4.0) erb (6.0.7) erb_lint (0.9.0) activesupport @@ -206,6 +209,17 @@ GEM activesupport (>= 4.2) jwt (3.3.0) base64 + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) @@ -256,8 +270,13 @@ GEM net-protocol net-protocol (0.4.0) timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.2) net-protocol + net-ssh (7.3.6) nio4r (2.7.5) nokogiri (1.19.4) mini_portile2 (~> 2.8.2) @@ -422,6 +441,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) tailwindcss-rails (4.6.0) @@ -433,6 +459,11 @@ GEM tailwindcss-ruby (4.3.3-x86_64-darwin) tailwindcss-ruby (4.3.3-x86_64-linux-gnu) thor (1.5.0) + thruster (0.1.27) + thruster (0.1.27-aarch64-linux) + thruster (0.1.27-arm64-darwin) + thruster (0.1.27-x86_64-darwin) + thruster (0.1.27-x86_64-linux) timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) @@ -488,6 +519,7 @@ DEPENDENCIES image_processing (~> 2.0) importmap-rails jsonapi-serializer + kamal madmin (~> 3.1) mailbin minitest-mock @@ -508,6 +540,7 @@ DEPENDENCIES solid_queue stimulus-rails (~> 1.0, >= 1.0.2) tailwindcss-rails (~> 4.0) + thruster turbo-rails (~> 2.0.3) tzinfo-data valid_email2 diff --git a/README.md b/README.md index b027063..55f0bcb 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,20 @@ bin/dev This starts up Overmind running the processes defined in `Procfile.dev`. We've configured this to run the Rails server out of the box. +## Deployment + +The API deploys with [Kamal 2](https://kamal-deploy.org) to any Linux server with SSH access: `Dockerfile` builds the image, `config/deploy.yml` describes the server, and images are pushed to GitHub Container Registry (ghcr.io). PostgreSQL runs on the same server as a Kamal accessory, and Solid Queue runs inside Puma, so one small VPS is enough. + +1. Edit `config/deploy.yml`: the server IP (`servers` and `accessories.db.host`), `image` and `registry.username` (your GitHub user or org), and `proxy.host` (also `app.domain` in `config/settings.yml`). +2. Export the secrets `.kamal/secrets` reads: `KAMAL_REGISTRY_PASSWORD` (a GitHub token with `write:packages`) and `NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD`. `RAILS_MASTER_KEY` comes from `config/credentials/production.key`. +3. First deploy: + +```bash +bin/kamal setup +``` + +Later deploys are `bin/kamal deploy`; `bin/kamal console`, `bin/kamal logs` and `bin/kamal dbc` are defined as aliases. + ## Contributing Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on reporting issues, proposing changes, and submitting pull requests. diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 67ef493..ed31659 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,7 +1,7 @@ #!/bin/bash -e # If running the rails server then create or migrate existing database -if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then ./bin/rails db:prepare fi diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 0000000..d9ba276 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/render-build.sh b/bin/render-build.sh deleted file mode 100755 index ba8689d..0000000 --- a/bin/render-build.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -# exit on error -set -o errexit - -while [ $# -gt 0 ] ; do - case $1 in - -s | --skip-migrations) SKIP_MIGRATE=true ;; - esac - shift -done - -bundle install -bundle exec rails assets:precompile -bundle exec rails assets:clean -[[ $SKIP_MIGRATE == true ]] || bundle exec rails db:migrate diff --git a/bin/render-seed.sh b/bin/render-seed.sh deleted file mode 100755 index 19f73d2..0000000 --- a/bin/render-seed.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -# exit on error -set -o errexit - -if [[ "${IS_PULL_REQUEST}" == "true" ]]; then - echo "IS_PULL_REQUEST is set. Setting staging environment variables and starting server." - export RAILS_ENV=staging - export RACK_ENV=staging - export RAILS_MASTER_KEY=${STAGING_RAILS_MASTER_KEY} - bundle exec rails db:seed_fu RAILS_ENV=staging -else - echo "IS_PULL_REQUEST is not set or is set to false. Setting production environment variables and starting server." - echo "Don't seed in production!" -fi diff --git a/bin/render-start.sh b/bin/render-start.sh deleted file mode 100755 index 8caad39..0000000 --- a/bin/render-start.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -# exit on error -set -o errexit - -export SOLID_QUEUE_IN_PUMA=${SOLID_QUEUE_IN_PUMA:-true} - -if [[ "${IS_PULL_REQUEST}" == "true" ]]; then - echo "IS_PULL_REQUEST is set. Setting staging environment variables and starting server." - export RAILS_ENV=staging - export RACK_ENV=staging - export RAILS_MASTER_KEY=${STAGING_RAILS_MASTER_KEY} - bundle exec rails s -e staging -else - echo "IS_PULL_REQUEST is not set or is set to false. Setting production environment variables and starting server." - export RAILS_ENV=production - export RACK_ENV=production - bundle exec rails s -e production -fi diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..b1c45d1 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'thrust' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config/cable.yml b/config/cable.yml index 8a83e49..fe1e6b3 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -4,14 +4,6 @@ development: test: adapter: test -staging: - adapter: solid_cable - connects_to: - database: - writing: cable - polling_interval: 0.1.seconds - message_retention: 1.day - production: adapter: solid_cable connects_to: diff --git a/config/cache.yml b/config/cache.yml index bd81f08..19d4908 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -11,10 +11,6 @@ development: test: <<: *default -staging: - database: cache - <<: *default - production: database: cache <<: *default diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc deleted file mode 100644 index 7dae223..0000000 --- a/config/credentials/staging.yml.enc +++ /dev/null @@ -1 +0,0 @@ -dxslcDu9tQt9BamJVi1nVs0o/PUjWCcIeqEqsZOJp80aMdaeCRXSs9pQWE2s5ToFQPeB2+3xhgPbDjhZ3p06JvazFXLi7nXk3HQeuLlAZWle1oemG3nUUWDD1QwwiKiRXuiW7DBgg8WL4k9Il791tfYzwCjcsKVABZCUfQPxjUNVTWb7hSBXRpNSx+PMM5ELyQNjSbzntRvz6qSHDEEfsyzCeY+l1z9JZ3ozmJ4GUHE8Ll7U2u2U+ibjKzuSYiLx0Bo+0qLwPrgcFNvcl0IK0xEgGq+b5jdeu0IoeN8lNUlGOz5RoyT4bnaYVkhh2KOF3wLtYfwCSx65+2Dc84Q0C24oycDaNFV0QqRRRjsT4/0lWjjy/WDrhS8K5x/txFjTyj9s/yngSDHHXxneKsldpBIu+QBND+vmQyEoxFu/SneACtXfM+4Xuz9dd2APSYBCmQJEnacaZLIx6GgMTcQAEyGPTXg8oLH1FcGma1VokgcZl/vUe72pQAiNdvR57zTE//YaEImu72GahQ9zIPQxctchfcjQ93jmaO6WN6POg5Qpb1ETlSgMDwPAM2oscc2uFHceXm9yy4T74a7X7fF+uP15uf052vtqHmONxRX1fhWL3hASSBGg+KzBCHt6bDzf50uqHItMx41GE5aNGlvMOnlkqnsDtonEEqvJVqOCxRHzteyxE6R1SYXUWHNaVdmuZZ6RG3M0pDB4WiXbyJkoNvSrRF1DuwWg3Xww9V46Zcqlg9OQxu1w9q8UExw84AvCUtkSg6vAPBoBxU3TwOOsdxaNkywP2+fnvlR3N5s9ciK0juSp9fE/i2ycTF2+MZ/LhibTyff4IosxU81hkyMMtTW3JXlJdIiDCqANAz4cv7ke7XsCsDebJWZItasZQqpfDP4Znal8USinZanm8Y3JSoWBAKWepsKZzEQ9hieNq0zA1F7JEZyBwahADiqeR75vwLQWtSxXNxAOF+lQCgSOUTau60Tb+fm4MvmnRVHF3VchZQdCEQ39aP5TNmHbS4A6YaCEhxocg5CRYF4Zk/b8G5zypKFnYbjVlsaye+vHz1fLb5hHXlHD3CUzIVssStxmiBpv5agwT0oTPv8vQKytaYP7YQLQz4mdkmGW8ODq/MiL/LeKI4cHcwdO7Lxd1nBQ9myZlHDtSLXbLXktUyBkBPlxNmQT9NERaO9l4oWhFP4R7vNgPxAHxczEmbw54N1+QYNohngyMI78Sh7/MFrBu7elmtDBgtITrY3i+a1bZS2iQ0Js7C/Wa3kgvUakdein8dTuvM2jqyqceF+ORoC2QZut3SoksgIFWZqK6ohYdPKbBJDRcSAT3c0oO/hCcUN95cb1QV2P/68p2S76Oph9w2Z5lFOW9g+qb8OGRF/aTLZ8Dme7IQSXaTGYZDrLHYw97DM4N5gsA4FuU3ohCVrqpAebR9y9y38eefL/Sr7dRIhA11bRXydSqdhnnQz1ONbsdeUa+0sRsnrg+MVMmxzgI0K4OQY2cK0PBoviCSwozJDBCKVbPRn/bop1zGTzv/SPgXVsVJuDi9Dt46Fwp13TsVidIHATTv8nuMTrXQ1HPtn8mj/mI2iWoytYKjGEu1L3126CoBD14H0i22n5V1qZL9hFPVyxrhXe+RYx1rQaiomInuttpBiTu9k8nLbGFkNmZ9u0Wpy3gRaBEqb/FDDpVonkRrDgAmFKaDSeB4JswZqsXEWWyKK2zWD7XuTMTIiveHV4Ps1ioGD4o2hmYNsCZR2rw60LZhM8iSvunpl8InqKaTDAYhHSHosltogGU14GSs8ymSPEqkacvm5ORaQZCPc/MBQspKuvdxpTJxb/HRB3Z0ybWpsVkt7J+PtNW9hLdAOpef01gsrC/0DegQo2sGV7lbsfqSZLmoG8XDrZrOdir6Py6seAnnAmAWA3wvimXXS7mKUSrTrfnFaVQ2XXKxznthU18lsb5r/QFoAWO/obv/YqLxhkt10+bBD9E96Ymz2s7AGgcU2+A0P+HA7GpUcQJk5A7a4Q5RY+aKe/18yaaGSQUerEsPCsyBCZ37U7lkGY+Z42HjHdO64e7gihFk5jeE5mzwu8UkZropEWSG4B2ZMgBrL5Y0qKLvvEDSb2YisxuYYA5HtcQsvPT72YO8nxXCb5GN1doPYO5KWRp2I3DacNBHqPx+jpobGR2aFQSN/v8LVxbEz6DkyY++tmcpVWNkiHNVQARXRIK//aoKcul+vYv87VKRPd0fRrOrNDAdD0EY0c+OR+kXug7OfNxL0h6claXJqMw/3UuiUc6pvd/AY3Oz6sC1gv86DL0bX0aOFL3RlJQyPK3lEsveCOH5+P4bxRZBlDOgM5fYqmB21yMBBVDUWN+FHbSU8j1iq/ZCVMl4jY/k6di39fVHJh29cmB0FkHe2uWUgjPBKYguDAJQW6Q61SGOJ313yRm1ofO6XGa317nx0c/pLzasSeqeeWAKs7oAwbgsP5ZLviiBCOqqDGKcjOUyJkh5dvizUVS3Njk++kG5qCxLJk0eaNp1ft0EFiWGzQUqlC3BpN3weMKlhXn3gui9M4UwcDCTjWCH9kt/XJ3CyLBlZgk0h9KwWr7Rthep7//9kQeOFkdCiwMJpPyvrvRPWrnQS9/3WRnkidDOrgS6tNHi/85E2/LOpKC+5A2bxQpZF/vohDUDhrymE4pQEMWmJxVY0tgCpXeGO1ylVy/PakVMKScBxxH+/gFL3M0swwsYt/2GRPWKGnF8VLSNHHacQulr6Y1BqODyy975UGvHOACyvtTKXwrA46lonkRbZOZ5vBYaWd3aEi53yQ4nOng/qvGXj8+WdIsMHj1ZMPeN+TMS8qPPyTjUsO+HfJ5UUB+WcFC7qjTmSLnhOa+efuhirq4VVwgYkGXVz2rEWcpUrvZBBYFMzE7NoHrN6oGWfv3PZdO6Z1jXp/7jR/l0aO4OE8jVdRkOwX/OaJtTnq2X4t08NNaHW7S22ZE4LOB9HGsCAkBonKZJ4AQ7yC5Bq1qu+IlrJ4xz/veq4FQyHmbTTVd7foX9AY3Rd81NEDWdYJHmQKalXuFGBGuHk9rL/nqm/U4rnL4Kyuoae4dUB8UiK2/xhnFtyqslw2V9e8OAEJgXsMP0j8Z3XwWBVAok9QvkUYq2RAJ9RP812bcfwhosMEJLKDO5XmMjbV1vzOVh3Jixe7hE4vyt3MeZiQ79g4h3n+PIGPhiuGjIfLom1ftpZWPhr+nLzis0MdWNu+ozdpSxxjjtIu4+geQZaeBg96uAqZrwNgm3sc8i/35LHCXJqwjgGQ0HZ8W2YJnM0ByoWCG5++hEjQ29OiyFVvnbBLi8nPoRhYj9Xg5/GRsY/SzOMWNkxXfZrM30dgkgz7HmSGo2w1LiGyR09ZVvTtdAkZ0mP8tdgw6Xk/ozCwy78+pdZOqesLlajQ1LkI8vCo8zbJe02AjqVDBRwmcmkFJomTV6kTouvzZ0HanF5lI20T3R/RrlB3IxkWxINJe5OUOcN1ii5+ds9aGSN8fxw4fg8+b7CI7negEMRclLa1EJuG9kHCmwCRcOUrnFYrnwee1JLLY27TOrYCtRyzzCbRVc5MKHsDF7hDpnl8InfE0lwOqRl5b1jFethfxC+iFT3qfZ9Vop5CmS7ujAXUKbjhQFvFa4Xa2lhPho2TJ93E0AP9Y5FGJRqBITfo7hJzzVorNVmRB998At7UyCEtJM00zHAMfBrIkihMxVmiJvJMO852lsJhytdgMxiI2rV2ZRVOLeFjN8KC496W8rtKLhGhftwJXkCpu4cao/n0p1CUbib+iN85iRSFEdsbWXgBscyZs/C+pXCBgdDvSHz8vO+ZqQeyPFyuEf2OCOzg2/1kjUUVvx2E2K6AQVKLuJgqzL7E0LpZIvlWRz9gMWa/OS0zOcPddrKtr2FbBn+5TqPxEGplw0QNCVbS1bAiUlq1EeQRas96/HLpOsWpoxCYV38S2sHlXIykvxzxO8upoZFTyH5ECCDmJbzhvMFIxDU10WpslqGziz5c8MKk4SSCM0QgZajo47zyQv+zRrEPKPDPQg15iOb5h+T1BBu8J/W+S8KL+q5KwsRGRYIrLIk++btcEo5+zmK1mlsJ8dw2IDaCdw+g6U5CAzmg4PwxKk6zP4tkXj076VHzE9A5xcg4NmsaLI43hkuNr5f4Nwfh4pBLvkyCKJRKTOjM4z1RVj2vFxQJQGSjRXo2EJsl1Zs+5+D5AB8Zirm/J3hDhzh1KJ1xeQDk5W4vrXjO13MM7j0Nxxw83AV392FnCOVGJ7z4sF5xqlu+uBYQd7GZAgPk0xV2OK+P8xxB5boplJ+rBHTSnB5AXRku1VVd84waPGeOC79Em3FAq4FErQLkZi5sZJnrTZZSm61MaM6k1BwT21XOvyEaplrOfOxPL7XDjpkx4Rcp4ccpphk+rR4xbtK31S81BJA8sCtP7gAdXyiB75XeLk33sgR7HNtVLzgFt0yMzazMDapOy/N+yxF4MIfY8ByK1uz4GWZic0YlwZqdfbHjZKWX1i+r5wjhcML2GjS2iDB04QHCHfk8SGkW7gucrH8RH4spoLFMi5uYekGTTV8zspeiZY3DG3RxqXB+kKo1eLVPEDox9kltZv1puB6NMeQS+JWPNROduhNLZfKIm5v/3TicstjHYXECoWuEa+ucXBOXFzkhKG00G9k0StkhyB50wCCZHJ05IpLB/uNX19pozny0URVLtF27s6S6R1RQUU3e--PNOh893SsKp1IuXM--QsOBsWGH5actxGQhOleEJQ== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index 8b0ab40..f09cac8 100644 --- a/config/database.yml +++ b/config/database.yml @@ -45,29 +45,6 @@ test: <<: *default database: nativeapptemplateapi_test -staging: - primary: &primary_staging - <<: *default - database: nativeapptemplateapi_staging - username: nativeapptemplateapi - password: <%= ENV["NATIVEAPPTEMPLATEAPI_DATABASE_PASSWORD"] %> - url: <%= ENV["DATABASE_URL"] %> - cache: - <<: *primary_staging - database: nativeapptemplateapi_staging_cache - migrations_paths: db/cache_migrate - url: <%= ENV["CACHE_DATABASE_URL"] %> - queue: - <<: *primary_staging - database: nativeapptemplateapi_staging_queue - migrations_paths: db/queue_migrate - url: <%= ENV["QUEUE_DATABASE_URL"] %> - cable: - <<: *primary_staging - database: nativeapptemplateapi_staging_cable - migrations_paths: db/cable_migrate - url: <%= ENV["CABLE_DATABASE_URL"] %> - # As with config/credentials.yml, you never want to store sensitive information, # like your database password, in your source code. If your source code is # ever seen by anyone, they now have access to your database. @@ -77,25 +54,26 @@ staging: # for a full rundown on how to provide these environment variables in a # production deployment. # +# Kamal sets DB_HOST (the "db" accessory's container name) and POSTGRES_PASSWORD +# (from .kamal/secrets) in config/deploy.yml. The username and primary database +# must match the accessory's POSTGRES_USER / POSTGRES_DB there; the cache, queue +# and cable databases are created by db:prepare on first boot. production: primary: &primary_production <<: *default database: nativeapptemplateapi_production username: nativeapptemplateapi - password: <%= ENV['NATIVEAPPTEMPLATEAPI_DATABASE_PASSWORD'] %> - url: <%= ENV['DATABASE_URL'] %> + password: <%= ENV["POSTGRES_PASSWORD"] %> + host: <%= ENV["DB_HOST"] %> cache: <<: *primary_production database: nativeapptemplateapi_production_cache migrations_paths: db/cache_migrate - url: <%= ENV["CACHE_DATABASE_URL"] %> queue: <<: *primary_production database: nativeapptemplateapi_production_queue migrations_paths: db/queue_migrate - url: <%= ENV["QUEUE_DATABASE_URL"] %> cable: <<: *primary_production database: nativeapptemplateapi_production_cable migrations_paths: db/cable_migrate - url: <%= ENV["CABLE_DATABASE_URL"] %> diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..98cf164 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,111 @@ +# Name of your application. Used to uniquely configure containers. +service: nativeapptemplateapi + +# Name of the container image: /. With the registry +# below the full name is ghcr.io/nativeapptemplate/nativeapptemplateapi. +image: nativeapptemplate/nativeapptemplateapi + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # Jobs run inside Puma (SOLID_QUEUE_IN_PUMA below). When you outgrow one box, + # move them to a dedicated host and set SOLID_QUEUE_IN_PUMA: false. + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +# Let's Encrypt needs port 80 reachable at issue time and at every renewal; if a +# firewall admits only Cloudflare, use a Cloudflare Origin Certificate instead: +# ssl: +# certificate_pem: ORIGIN_CERT # add both to .kamal/secrets, read from files +# private_key_pem: ORIGIN_KEY +# forward_headers: true # ssl: stops forwarding X-Forwarded-For otherwise, +# # and every caller would look like Cloudflare to rate_limit +# ssl_redirect: false # Rails force_ssl already redirects on X-Forwarded-Proto +proxy: + ssl: true + host: api.nativeapptemplate.com + +# Credentials for your image host. +registry: + # GitHub Container Registry. + server: ghcr.io + username: nativeapptemplate + + # A GitHub Personal Access Token (classic) with the write:packages scope. + # Pulled from .kamal/secrets (never hard-code it here). + password: + - KAMAL_REGISTRY_PASSWORD + +# Configure builder setup. +builder: + # ARM servers (e.g. Hetzner CAX, AWS Graviton) build natively on Apple Silicon. + # Use amd64 for x86 servers. + arch: arm64 + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + - POSTGRES_PASSWORD + clear: + # Run the Solid Queue supervisor inside Puma, including the recurring tasks in + # config/recurring.yml. No separate job container or cron. + SOLID_QUEUE_IN_PUMA: true + + # One Puma worker (RAILS_MAX_THREADS threads). Raise it on a box with spare cores. + WEB_CONCURRENCY: 1 + + # Postgres runs as the "db" accessory below. The app reaches it by container + # name ("-") over the private "kamal" Docker network, not + # the public IP, so the database is never exposed to the internet. + # config/database.yml reads this. + DB_HOST: nativeapptemplateapi-db + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + +# Use a persistent storage volume for local Active Storage files. +volumes: + - "nativeapptemplateapi_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Use accessory services (secrets come from .kamal/secrets). +accessories: + db: + # Same major as the CI service in .github/workflows/ci.yml. + image: postgres:18 + # Same server as the app. + host: 192.168.0.1 + # Publish only on the host's loopback interface, for server-side admin work + # (psql, pg_dump). The app does not use this; it connects over the Docker + # network (DB_HOST above). Without the 127.0.0.1 prefix Kamal would publish + # on 0.0.0.0 and expose Postgres publicly. Pick another host port if several + # apps with their own db accessory share one server. + port: "127.0.0.1:5432:5432" + env: + clear: + # Must match config/database.yml production: username + primary database. + # The cache/queue/cable databases are created by db:prepare on first boot. + POSTGRES_USER: nativeapptemplateapi + POSTGRES_DB: nativeapptemplateapi_production + secret: + - POSTGRES_PASSWORD + directories: + # postgres:18 images keep PGDATA under /var/lib/postgresql (not .../data). + - data:/var/lib/postgresql diff --git a/config/environments/production.rb b/config/environments/production.rb index 71c28fc..b6b10e5 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -36,8 +36,10 @@ # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - # config.assume_ssl = true + # kamal-proxy terminates TLS and reaches the container over plain HTTP, so + # without this force_ssl would answer every request (including kamal-proxy's + # /up health check) with a redirect to https. + config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = true @@ -79,13 +81,13 @@ config.active_record.dump_schema_after_migration = false # Enable DNS rebinding protection and other `Host` header attacks. - # Render automatically sets RENDER_EXTERNAL_HOSTNAME to the service's hostname - # (e.g. nativeapptemplateapi.onrender.com). No manual configuration needed. - # APP_HOST is for the custom domain (set in Render env vars). + # The API domain is app.domain in config/settings.yml (also proxy.host in + # config/deploy.yml). APP_HOST admits one more hostname without a code change, + # e.g. while moving domains. config.hosts = [ - ENV["RENDER_EXTERNAL_HOSTNAME"], + ConfigSettings.app.domain, ENV["APP_HOST"] - ].compact + ].compact.uniq # Skip DNS rebinding protection for the default health check endpoint. config.host_authorization = {exclude: ->(request) { request.path == "/up" }} end diff --git a/config/environments/staging.rb b/config/environments/staging.rb deleted file mode 100644 index bec8207..0000000 --- a/config/environments/staging.rb +++ /dev/null @@ -1,109 +0,0 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # Code is not reloaded between requests. - config.enable_reloading = false - - # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). - config.eager_load = true - - # Full error reports are disabled. - config.consider_all_requests_local = false - - # Turn on fragment caching in view templates. - config.action_controller.perform_caching = true - - # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"} - - # Enable serving of images, stylesheets, and JavaScripts from an asset server. - # config.asset_host = "http://assets.example.com" - - # Compress CSS using a preprocessor. - config.assets.css_compressor = nil - - # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false - - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local - - # Mount Action Cable outside main process or domain. - # config.action_cable.mount_path = nil - # config.action_cable.url = 'wss://example.com/cable' - # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] - - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - # config.assume_ssl = true - - # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true - - # Skip http-to-https redirect for the default health check endpoint. - # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } - - # Log to STDOUT with the current request id as a default log tag. - config.log_tags = [:request_id] - config.logger = ActiveSupport::TaggedLogging.logger($stdout) - - # Change to "debug" to log everything (including potentially personally-identifiable information!) - config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - - # Prevent health checks from clogging up the logs. - config.silence_healthcheck_path = "/up" - - # Don't log any deprecations. - config.active_support.report_deprecations = false - - # Use SolidQueue for Active Job (database-backed) - config.active_job.queue_adapter = :solid_queue - config.solid_queue.connects_to = {database: {writing: :queue}} - - # Use SolidCache for caching - config.cache_store = :solid_cache_store - - config.action_mailer.perform_caching = false - - # Ignore bad email addresses and do not raise email delivery errors. - # Set this to true and configure the email server for immediate delivery to raise delivery errors. - # config.action_mailer.raise_delivery_errors = false - - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation cannot be found). - config.i18n.fallbacks = true - - # Send deprecation notices to registered listeners. - config.active_support.deprecation = :notify - - # Do not dump schema after migrations. - config.active_record.dump_schema_after_migration = false - - # Enable DNS rebinding protection and other `Host` header attacks. - # config.hosts = [ - # "example.com", # Allow requests from example.com - # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` - # ] - # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } - - # https://www.cjav.dev/articles/setting-up-pr-previews-for-rails-on-render-com - service_name = ENV.fetch("RENDER_SERVICE_NAME", "nativeapptemplateapi-pr-101") - staging_host = ENV["RENDER_EXTERNAL_HOSTNAME"] - if staging_host.blank? - staging_host = "nativeapptemplateapi-#{service_name.match(/pr-\d+/)}.onrender.com" - end - staging_url = "https://#{staging_host}" - - puts "RENDER_EXTERNAL_HOSTNAME: #{ENV["RENDER_EXTERNAL_HOSTNAME"]}" - puts "RENDER_EXTERNAL_URL: #{ENV["RENDER_EXTERNAL_URL"]}" - puts "Using staging host #{staging_host} and staging url #{staging_url}" - - Rails.application.routes.default_url_options[:host] = staging_host - - config.action_controller.asset_host = staging_url - config.action_mailer.asset_host = staging_url - config.asset_host = staging_url -end diff --git a/config/initializers/mail.rb b/config/initializers/mail.rb index ca7ea36..9c164f0 100644 --- a/config/initializers/mail.rb +++ b/config/initializers/mail.rb @@ -2,11 +2,8 @@ Rails.application.reloader.to_prepare do ActionMailer::Base.default_options = {from: ConfigSettings.email.default_from} - if Rails.env.production? || Rails.env.staging? - # PR previews (staging) set their own Render host in staging.rb. Their emails - # must link back to that preview, whose tokens don't exist in production. - ActionMailer::Base.default_url_options[:host] = - Rails.application.routes.default_url_options[:host] || ConfigSettings.app.domain + if Rails.env.production? + ActionMailer::Base.default_url_options[:host] = ConfigSettings.app.domain ActionMailer::Base.default_url_options[:protocol] = "https" ActionMailer::Base.delivery_method = :resend diff --git a/config/queue.yml b/config/queue.yml index 917317c..9eace59 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -14,8 +14,5 @@ development: test: <<: *default -staging: - <<: *default - production: <<: *default diff --git a/config/recurring.yml b/config/recurring.yml index 7e1b1b8..a643a4c 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -8,8 +8,5 @@ default: &default command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 -staging: - <<: *default - production: <<: *default diff --git a/config/routes.rb b/config/routes.rb index f443a72..17db524 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,9 @@ Rails.application.routes.draw do + # Health check for kamal-proxy (and load balancers): 200 while the app boots + # and reaches the database, 500 otherwise. Exempt from host authorization and + # request logging in config/environments/production.rb. + get "up", to: "rails/health#show", as: :rails_health_check + draw :madmin require "admin_constraint" diff --git a/db/fixtures/staging/01_permissions.rb b/db/fixtures/staging/01_permissions.rb deleted file mode 100644 index ddb2852..0000000 --- a/db/fixtures/staging/01_permissions.rb +++ /dev/null @@ -1,9 +0,0 @@ -Permission.seed( - :tag, - {name: "create shops", tag: "create_shops", position: 1}, - {name: "update shops", tag: "update_shops", position: 2}, - {name: "delete shops", tag: "delete_shops", position: 3}, - {name: "update organizations", tag: "update_organizations", position: 4}, - {name: "invitation", tag: "invitation", position: 5}, - {name: "read data", tag: "read_data", position: 6} -) diff --git a/db/fixtures/staging/02_roles.rb b/db/fixtures/staging/02_roles.rb deleted file mode 100644 index 4d03ac4..0000000 --- a/db/fixtures/staging/02_roles.rb +++ /dev/null @@ -1,5 +0,0 @@ -Role.seed( - :tag, - {name: "admin", tag: "admin", position: 1}, - {name: "member", tag: "member", position: 2} -) diff --git a/db/fixtures/staging/03_roles_permissions.rb b/db/fixtures/staging/03_roles_permissions.rb deleted file mode 100644 index 8ed1845..0000000 --- a/db/fixtures/staging/03_roles_permissions.rb +++ /dev/null @@ -1,25 +0,0 @@ -admin_role = Role.find_by(tag: "admin") -member_role = Role.find_by(tag: "member") - -create_shops_permission = Permission.find_by(tag: "create_shops") -update_shops_permission = Permission.find_by(tag: "update_shops") -delete_shops_permission = Permission.find_by(tag: "delete_shops") -update_organizations_permission = Permission.find_by(tag: "update_organizations") -invitation_permission = Permission.find_by(tag: "invitation") -read_data_permission = Permission.find_by(tag: "read_data") - -RolesPermission.seed( - :role_id, :permission_id, - # admin: all permissions - {role_id: admin_role.id, permission_id: create_shops_permission.id}, - {role_id: admin_role.id, permission_id: update_shops_permission.id}, - {role_id: admin_role.id, permission_id: delete_shops_permission.id}, - {role_id: admin_role.id, permission_id: update_organizations_permission.id}, - {role_id: admin_role.id, permission_id: invitation_permission.id}, - {role_id: admin_role.id, permission_id: read_data_permission.id}, - # member: CRUD on shops + read_data (no organization management or invitation) - {role_id: member_role.id, permission_id: create_shops_permission.id}, - {role_id: member_role.id, permission_id: update_shops_permission.id}, - {role_id: member_role.id, permission_id: delete_shops_permission.id}, - {role_id: member_role.id, permission_id: read_data_permission.id} -) diff --git a/db/fixtures/staging/10_app_versions.rb b/db/fixtures/staging/10_app_versions.rb deleted file mode 100644 index 5e19e6b..0000000 --- a/db/fixtures/staging/10_app_versions.rb +++ /dev/null @@ -1,12 +0,0 @@ -require "csv" - -CSV.parse(File.readlines("db/data/app_versions.csv").drop(2).join) do |row| - AppVersion.seed(:platform, :version) do |app_version| - app_version.platform = row[0] - app_version.version = row[1].to_i - app_version.current_type = row[2].to_i - app_version.forced_update_type = row[3].to_i - app_version.title = row[4] - app_version.description = row[5] - end -end diff --git a/db/fixtures/staging/11_privacy_versions.rb b/db/fixtures/staging/11_privacy_versions.rb deleted file mode 100644 index c29b3c2..0000000 --- a/db/fixtures/staging/11_privacy_versions.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "csv" - -CSV.parse(File.readlines("db/data/privacy_versions.csv").drop(2).join) do |row| - PrivacyVersion.seed(:version) do |privacy_version| - privacy_version.version = row[0].to_i - privacy_version.current_type = row[1].to_i - privacy_version.published_at = row[2] - privacy_version.title = row[3] - privacy_version.description = row[4] - end -end diff --git a/db/fixtures/staging/12_terms_versions.rb b/db/fixtures/staging/12_terms_versions.rb deleted file mode 100644 index 0d75ed7..0000000 --- a/db/fixtures/staging/12_terms_versions.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "csv" - -CSV.parse(File.readlines("db/data/terms_versions.csv").drop(2).join) do |row| - TermsVersion.seed(:version) do |terms_version| - terms_version.version = row[0].to_i - terms_version.current_type = row[1].to_i - terms_version.published_at = row[2] - terms_version.title = row[3] - terms_version.description = row[4] - end -end diff --git a/docs/nativeapptemplate-substrate-v2-overview.md b/docs/nativeapptemplate-substrate-v2-overview.md index 51791c4..23a26be 100644 --- a/docs/nativeapptemplate-substrate-v2-overview.md +++ b/docs/nativeapptemplate-substrate-v2-overview.md @@ -496,7 +496,7 @@ The Rails API uses a custom fixture system in `db/fixtures//` (not standard ### 8.4 What to watch for (common pitfalls) -- Fixture files exist per-environment (`db/fixtures/{development,test,staging,production}/`). Changes must apply to all 4. +- Fixture files exist per-environment (`db/fixtures/{development,test,production}/`). Changes must apply to all 3. - `rolified.rb` concern may hard-code role tags — check before changing roles. - Shop model may reference ItemTag via `has_many` and instance methods (`reset!`, `latest_completed_item_tag`, `create_default_item_tags!`). Remove cleanly. - `full_reload_entire_page` on Shop uses `turbo_stream` — check all callers before removing. diff --git a/docs/phase1-rails-api.md b/docs/phase1-rails-api.md index fca6ff9..2070fed 100644 --- a/docs/phase1-rails-api.md +++ b/docs/phase1-rails-api.md @@ -553,7 +553,7 @@ git commit -m "Update madmin resource for new ItemTag schema" ### 12.1 Edit fixture files -For each environment (`development`, `test`, `staging`, `production`): +For each environment (`development`, `test`, `production`): **`db/fixtures//01_permissions.rb`** — replace with: diff --git a/render.yaml b/render.yaml deleted file mode 100644 index 9aaf2c8..0000000 --- a/render.yaml +++ /dev/null @@ -1,66 +0,0 @@ -# Example Render configuration. You will need to adjust this for the different services you run. -previews: - generation: manual - expireAfterDays: 3 -services: - - type: web - repo: https://github.com/nativeapptemplate/nativeapptemplateapi - name: nativeapptemplateapi - plan: standard - previews: - plan: standard - env: ruby - region: singapore # the region must be consistent across all services for the internal keys to be read - buildCommand: "./bin/render-build.sh" - startCommand: "./bin/render-start.sh" - initialDeployHook: "./bin/render-seed.sh" - envVars: - - key: RAILS_MASTER_KEY - sync: false - - key: WEB_CONCURRENCY - value: 1 - - key: SOLID_QUEUE_IN_PUMA - value: true - - key: DATABASE_URL - fromDatabase: - name: nativeapptemplateapi - property: connectionString - - key: CACHE_DATABASE_URL - fromDatabase: - name: nativeapptemplateapi-cache - property: connectionString - - key: QUEUE_DATABASE_URL - fromDatabase: - name: nativeapptemplateapi-queue - property: connectionString - - key: CABLE_DATABASE_URL - fromDatabase: - name: nativeapptemplateapi-cable - property: connectionString - - fromGroup: nativeapptemplateapi-env - -databases: - - name: nativeapptemplateapi - plan: basic-256mb - previewPlan: basic-256mb - region: singapore # the region must be consistent across all services for the internal keys to be read - ipAllowList: [] # only allow internal connections - diskSizeGB: 5 - - - name: nativeapptemplateapi-cache - plan: basic-256mb - previewPlan: basic-256mb - region: singapore - ipAllowList: [] - - - name: nativeapptemplateapi-queue - plan: basic-256mb - previewPlan: basic-256mb - region: singapore - ipAllowList: [] - - - name: nativeapptemplateapi-cable - plan: basic-256mb - previewPlan: basic-256mb - region: singapore - ipAllowList: [] diff --git a/test/config/deploy_config_test.rb b/test/config/deploy_config_test.rb new file mode 100644 index 0000000..2240da5 --- /dev/null +++ b/test/config/deploy_config_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +# config/deploy.yml, .kamal/secrets, config/database.yml and the Dockerfile +# describe one deployment between them, and nothing checks them against each +# other until the first `kamal deploy`. These checks keep the halves in step. +class DeployConfigTest < ActiveSupport::TestCase + DEPLOY = YAML.load_file(Rails.root.join("config/deploy.yml")) + SERVICE = "nativeapptemplateapi" + + # The accessory boots Postgres with POSTGRES_USER / POSTGRES_DB; Active Record + # connects with database.yml's production username / database. They must agree + # or the first db:prepare fails with "role does not exist". + test "db accessory creates the role and database that database.yml connects to" do + db = ActiveRecord::Base.configurations.configs_for(env_name: "production", name: "primary").configuration_hash + accessory_env = DEPLOY.dig("accessories", "db", "env", "clear") + + assert_equal db[:username], accessory_env["POSTGRES_USER"] + assert_equal db[:database], accessory_env["POSTGRES_DB"] + end + + # Kamal names an accessory container "-" and the app + # reaches it by that name on the kamal Docker network (Kamal docs: Accessories). + test "DB_HOST is the db accessory's container name" do + assert_equal "#{DEPLOY["service"]}-db", DEPLOY.dig("env", "clear", "DB_HOST") + assert_equal SERVICE, DEPLOY["service"] + end + + # database.yml reads POSTGRES_PASSWORD and DB_HOST; both must arrive in the + # app container, and the accessory needs the same password. + test "app and db accessory receive the Postgres password" do + assert_includes DEPLOY.dig("env", "secret"), "POSTGRES_PASSWORD" + assert_includes DEPLOY.dig("env", "secret"), "RAILS_MASTER_KEY" + assert_includes DEPLOY.dig("accessories", "db", "env", "secret"), "POSTGRES_PASSWORD" + end + + # Kamal resolves every env.secret, registry password and accessory secret from + # .kamal/secrets; an unknown name aborts the deploy. + test ".kamal/secrets defines every secret deploy.yml references" do + defined = File.readlines(Rails.root.join(".kamal/secrets")).filter_map { |line| line[/\A([A-Z0-9_]+)=/, 1] } + referenced = DEPLOY.dig("env", "secret") + DEPLOY.dig("registry", "password") + DEPLOY.dig("accessories", "db", "env", "secret") + + assert_empty referenced - defined + end + + test "images are pushed to GitHub Container Registry" do + assert_equal "ghcr.io", DEPLOY.dig("registry", "server") + assert_match %r{\A[\w-]+/#{SERVICE}\z}, DEPLOY["image"] + end + + # Jobs and config/recurring.yml run inside Puma; without this flag and with no + # job server, nothing would process the queue. + test "Solid Queue runs in Puma because there is no job server" do + assert_equal true, DEPLOY.dig("env", "clear", "SOLID_QUEUE_IN_PUMA") + assert_nil DEPLOY.dig("servers", "job") + end + + # proxy.host is what kamal-proxy routes and gets a certificate for; Rails + # rejects any other Host header (config.hosts in production.rb). + test "proxy host is the API domain Rails accepts" do + assert_equal ConfigSettings.app.domain, DEPLOY.dig("proxy", "host") + end + + # Active Storage's :local service writes under storage/ (config/storage.yml), + # which is /rails/storage in the image; without a volume uploads vanish on deploy. + test "Active Storage files live on a persistent volume" do + assert_includes DEPLOY["volumes"], "#{SERVICE}_storage:/rails/storage" + end + + # The Postgres accessory only publishes a host port for server-side admin work; + # a bare "5432:5432" would bind 0.0.0.0 and expose the database. + test "db accessory is published on loopback only" do + assert_match %r{\A127\.0\.0\.1:\d+:5432\z}, DEPLOY.dig("accessories", "db", "port") + end + + test "Dockerfile builds the Ruby in .ruby-version" do + ruby_version = Rails.root.join(".ruby-version").read.strip + dockerfile = Rails.root.join("Dockerfile").read + + assert_includes dockerfile, "ARG RUBY_VERSION=#{ruby_version}" + end + + # bin/setup and CI install gems into vendor/bundle for the host platform; copying + # them into the image breaks `bundle install` there and bloats the image. + test "Docker build context excludes vendor/bundle" do + assert_includes Rails.root.join(".dockerignore").readlines.map(&:strip), "/vendor/bundle" + end +end diff --git a/test/config/mailer_host_test.rb b/test/config/mailer_host_test.rb index dca28e0..aa08158 100644 --- a/test/config/mailer_host_test.rb +++ b/test/config/mailer_host_test.rb @@ -1,20 +1,11 @@ require "test_helper" require "open3" -# Confirmation and password reset emails link back to the API host. A PR -# preview (staging) must link to itself: its tokens don't exist in production. +# Confirmation and password reset emails link back to the API host. # -# Booting another environment needs its credentials key, which only developer -# machines have, so this skips in CI. +# Booting the production environment needs its credentials key, which only +# developer machines have, so this skips in CI. class MailerHostTest < ActiveSupport::TestCase - PREVIEW_HOST = "nativeapptemplateapi-pr-999.onrender.com" - - test "staging emails link to the preview's own host" do - skip "needs config/credentials/staging.key" unless Rails.root.join("config/credentials/staging.key").exist? - - assert_equal({host: PREVIEW_HOST, protocol: "https"}, mailer_url_options("staging", "RENDER_EXTERNAL_HOSTNAME" => PREVIEW_HOST)) - end - test "production emails link to the production API domain" do skip "needs config/credentials/production.key" unless Rails.root.join("config/credentials/production.key").exist? @@ -23,14 +14,14 @@ class MailerHostTest < ActiveSupport::TestCase private - def mailer_url_options(env, extra_env = {}) + def mailer_url_options(env) db = ActiveRecord::Base.connection_db_config.configuration_hash db_url = "postgres://#{db[:username] || ENV.fetch("USER")}@#{db[:host] || "localhost"}/#{db[:database]}" vars = { "RAILS_ENV" => env, "DATABASE_URL" => db_url, "CACHE_DATABASE_URL" => db_url, "QUEUE_DATABASE_URL" => db_url, "CABLE_DATABASE_URL" => db_url - }.merge(extra_env) + } out, status = Open3.capture2e(vars, "bin/rails", "runner", "puts ActionMailer::Base.default_url_options.to_json", chdir: Rails.root) assert status.success?, out diff --git a/test/config/recurring_tasks_test.rb b/test/config/recurring_tasks_test.rb index e93fc8d..cff62f4 100644 --- a/test/config/recurring_tasks_test.rb +++ b/test/config/recurring_tasks_test.rb @@ -10,14 +10,12 @@ class RecurringTasksTest < ActiveSupport::TestCase CONFIG = ActiveSupport::ConfigurationFile.parse(Rails.root.join("config/recurring.yml")) - %w[production staging].each do |env| - test "#{env} clears finished Solid Queue jobs" do - options = CONFIG.dig(env, "clear_solid_queue_finished_jobs") - assert options, "config/recurring.yml has no clear_solid_queue_finished_jobs task for #{env}" + test "production clears finished Solid Queue jobs" do + options = CONFIG.dig("production", "clear_solid_queue_finished_jobs") + assert options, "config/recurring.yml has no clear_solid_queue_finished_jobs task for production" - assert_equal "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)", options["command"] - assert_includes SolidQueue::Job.method(:clear_finished_in_batches).parameters, [:key, :sleep_between_batches] - end + assert_equal "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)", options["command"] + assert_includes SolidQueue::Job.method(:clear_finished_in_batches).parameters, [:key, :sleep_between_batches] end test "every recurring task has a command and a supported schedule" do diff --git a/test/integration/health_check_test.rb b/test/integration/health_check_test.rb new file mode 100644 index 0000000..e56d612 --- /dev/null +++ b/test/integration/health_check_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +# kamal-proxy only routes traffic to a container once GET /up answers 2xx/3xx +# (Kamal's default health check path), and config/environments/production.rb +# already exempts /up from host authorization and request logging. +class HealthCheckTest < ActionDispatch::IntegrationTest + test "GET /up answers 200 without authentication" do + get "/up" + + assert_response :ok + end +end From 52aa07c5db026e170b6e8a17a7c5b6ed23015ebf Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 10 Oct 2026 16:13:43 +0900 Subject: [PATCH 2/3] Serve a Cloudflare Origin Certificate and trust Cloudflare's ranges The fleet these apps join sits behind Cloudflare "Full (strict)" with a firewall admitting 443 from Cloudflare only and no port 80, so Let's Encrypt cannot issue or renew there. kamal-proxy now serves a Cloudflare Origin Certificate read from files by .kamal/secrets (NATIVEAPPTEMPLATEAPI_ORIGIN_CERT / _KEY), with forward_headers: true and ssl_redirect: false; the Let's Encrypt variant stays as a comment. config/cloudflare_ips.yml (fetched 2026-10-10) is added to config.action_dispatch.trusted_proxies so request.remote_ip, and with it rate_limit, resolves past the edge to the caller instead of giving every caller behind one edge a single bucket. test/integration/trusted_proxies_test.rb proves it through 429s (two callers behind one edge are separate; one caller across two edges is still throttled) and pins the deploy.yml half (forward_headers, ssl_redirect, certificate from secrets). Seen failing before the application.rb change: 2 failures, 3 errors. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WyUzNEPWyhjjWrBcSaXSW4 --- .kamal/secrets | 10 ++ AGENTS.md | 2 +- README.md | 19 +++- config/application.rb | 9 ++ config/cloudflare_ips.yml | 47 +++++++++ config/deploy.yml | 43 ++++++--- test/config/deploy_config_test.rb | 3 +- test/integration/trusted_proxies_test.rb | 118 +++++++++++++++++++++++ 8 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 config/cloudflare_ips.yml create mode 100644 test/integration/trusted_proxies_test.rb diff --git a/.kamal/secrets b/.kamal/secrets index 8110f2f..d7bbe08 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -20,3 +20,13 @@ RAILS_MASTER_KEY=$(cat config/credentials/production.key) # It is baked into the Postgres data volume on the FIRST deploy. Changing it # later requires also altering the role inside Postgres. POSTGRES_PASSWORD=$NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD + +# Cloudflare Origin certificate for kamal-proxy (proxy.ssl in config/deploy.yml). +# Read from files, not environment variables: a PEM is multi-line and does not +# survive `export` in a shell rc file. Create them on the deploying machine, +# mode 0600, outside the repo (README "Deployment" has the commands): +# ~/.config/nativeapptemplateapi/origin-cert.pem +# ~/.config/nativeapptemplateapi/origin-key.pem +# Kamal fails the deploy if either is missing or not a valid PEM. +NATIVEAPPTEMPLATEAPI_ORIGIN_CERT=$(cat $HOME/.config/nativeapptemplateapi/origin-cert.pem) +NATIVEAPPTEMPLATEAPI_ORIGIN_KEY=$(cat $HOME/.config/nativeapptemplateapi/origin-key.pem) diff --git a/AGENTS.md b/AGENTS.md index 325ff9e..1d30b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f ### Deployment - **This repo itself is never deployed.** It is the template that apps are generated from, so there is no production environment or production data. Don't weigh changes against deploy risk (migration downtime, existing records); do keep the Kamal config below correct, because generated apps inherit it. - Deployed with [Kamal 2](https://kamal-deploy.org) (`bin/kamal`): `Dockerfile` builds the image, `config/deploy.yml` describes the server, and `.kamal/secrets` reads secrets from the environment and `config/credentials/production.key`. Images go to GitHub Container Registry (`ghcr.io`). -- One server runs everything: the app (Puma behind Thruster on port 80, fronted by kamal-proxy with Let's Encrypt), and PostgreSQL as the `db` accessory on the private Docker network (`DB_HOST=-db`; never published to the internet). Solid Queue runs inside Puma via `SOLID_QUEUE_IN_PUMA=true`, so there is no separate job server. +- One server runs everything: the app (Puma behind Thruster on port 80, fronted by kamal-proxy serving a Cloudflare Origin Certificate with `forward_headers: true` and `ssl_redirect: false`; Cloudflare's ranges are in `config.action_dispatch.trusted_proxies` via `config/cloudflare_ips.yml` so `rate_limit` sees the caller, not the edge), and PostgreSQL as the `db` accessory on the private Docker network (`DB_HOST=-db`; never published to the internet). Solid Queue runs inside Puma via `SOLID_QUEUE_IN_PUMA=true`, so there is no separate job server. - Everything named `nativeapptemplateapi` in `config/deploy.yml`, `.kamal/secrets` and `config/database.yml` is renamed together by the agent; the accessory's `POSTGRES_USER`/`POSTGRES_DB` must keep matching `database.yml`'s production `username`/`database` (`test/config/deploy_config_test.rb` checks this). - The only environments are `development`, `test` and `production`. There is no staging: Render PR previews were the only thing that used it. diff --git a/README.md b/README.md index 55f0bcb..483160c 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,26 @@ This starts up Overmind running the processes defined in `Procfile.dev`. We've c ## Deployment -The API deploys with [Kamal 2](https://kamal-deploy.org) to any Linux server with SSH access: `Dockerfile` builds the image, `config/deploy.yml` describes the server, and images are pushed to GitHub Container Registry (ghcr.io). PostgreSQL runs on the same server as a Kamal accessory, and Solid Queue runs inside Puma, so one small VPS is enough. +The API deploys with [Kamal 2](https://kamal-deploy.org) to any Linux server with SSH access: `Dockerfile` builds the image, `config/deploy.yml` describes the server, and images are pushed to GitHub Container Registry (ghcr.io). PostgreSQL runs on the same server as a Kamal accessory, and Solid Queue runs inside Puma, so one small VPS is enough. The server sits behind Cloudflare: the zone's SSL/TLS mode is "Full (strict)", kamal-proxy serves a Cloudflare Origin Certificate, and the server firewall admits port 443 from [Cloudflare's ranges](https://www.cloudflare.com/ips/) only. (Without Cloudflare, switch `proxy` in `config/deploy.yml` to the Let's Encrypt variant shown in its comment.) 1. Edit `config/deploy.yml`: the server IP (`servers` and `accessories.db.host`), `image` and `registry.username` (your GitHub user or org), and `proxy.host` (also `app.domain` in `config/settings.yml`). 2. Export the secrets `.kamal/secrets` reads: `KAMAL_REGISTRY_PASSWORD` (a GitHub token with `write:packages`) and `NATIVEAPPTEMPLATEAPI_POSTGRES_PASSWORD`. `RAILS_MASTER_KEY` comes from `config/credentials/production.key`. -3. First deploy: +3. Create an Origin Certificate in the Cloudflare dashboard (SSL/TLS → Origin Server) and save both halves outside the repo. Copying from the dashboard can lose the line breaks, so rewrap while saving: + +```bash +pem() { ruby -e ' + s = STDIN.read + m = s.match(/-----BEGIN ([A-Z ]+)-----(.*?)-----END \1-----/m) or abort "not a PEM" + puts "-----BEGIN #{m[1]}-----", m[2].gsub(/\s+/, "").scan(/.{1,64}/), "-----END #{m[1]}-----" +' ; } +mkdir -p ~/.config/nativeapptemplateapi && chmod 700 ~/.config/nativeapptemplateapi +pbpaste | pem > ~/.config/nativeapptemplateapi/origin-cert.pem # "Origin Certificate" copied +pbpaste | pem > ~/.config/nativeapptemplateapi/origin-key.pem # "Private Key" copied +chmod 600 ~/.config/nativeapptemplateapi/origin-*.pem +openssl x509 -in ~/.config/nativeapptemplateapi/origin-cert.pem -noout -subject -enddate +``` + +4. First deploy: ```bash bin/kamal setup diff --git a/config/application.rb b/config/application.rb index 547e78e..0d0f96c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -45,6 +45,15 @@ class Application < Rails::Application # https://github.com/heartcombo/devise/issues/4825 config.wrap_parameters = false + # Cloudflare fronts the origin, so request.remote_ip must resolve past its + # edge to the caller (config/cloudflare_ips.yml explains what reads it and + # why: Rails' rate_limit keys on remote_ip). `+`, not `=`: assigning would + # drop the loopback and private ranges Rails trusts by default, and + # kamal-proxy reaches the container over exactly such an address. + config.action_dispatch.trusted_proxies = + ActionDispatch::RemoteIp::TRUSTED_PROXIES + + YAML.load_file(Rails.root.join("config/cloudflare_ips.yml")).values.flatten.map { |range| IPAddr.new(range) } + require "middleware/account_middleware" config.middleware.use AccountMiddleware diff --git a/config/cloudflare_ips.yml b/config/cloudflare_ips.yml new file mode 100644 index 0000000..420d800 --- /dev/null +++ b/config/cloudflare_ips.yml @@ -0,0 +1,47 @@ +# Cloudflare's published edge ranges, the proxy in front of this origin. +# +# Fetched 2026-10-10 from https://www.cloudflare.com/ips-v4 and +# https://www.cloudflare.com/ips-v6, verbatim. Re-fetch rather than edit by hand. +# +# config/application.rb adds these to config.action_dispatch.trusted_proxies so +# request.remote_ip resolves past the Cloudflare hop to the caller. Rails' +# rate_limit keys on remote_ip: without this, every caller behind one edge +# shares a single bucket (the API answers 429 to people who sent one request), +# and the sign-in limit stops being brute force protection (an attacker's +# attempts are spread across edges and never reach the limit). +# +# This is half of the fix. proxy.forward_headers: true in config/deploy.yml is +# the other half: kamal-proxy drops Cloudflare's X-Forwarded-For when ssl: is +# set, leaving no hop to resolve past. test/integration/trusted_proxies_test.rb +# asserts both halves. +# +# Why a list rather than CF-Connecting-IP: a header is only safe to trust if +# nothing but Cloudflare can reach the origin, and a request that does arrive +# directly is then attributed to its own address by this list (RemoteIp drops the +# trusted hops and keeps the rightmost untrusted one), not to whatever the sender +# wrote. Note 172.64.0.0/13 (172.64 to 172.71) is outside the private range +# 172.16.0.0/12 that Rails already trusts. +v4: + - 173.245.48.0/20 + - 103.21.244.0/22 + - 103.22.200.0/22 + - 103.31.4.0/22 + - 141.101.64.0/18 + - 108.162.192.0/18 + - 190.93.240.0/20 + - 188.114.96.0/20 + - 197.234.240.0/22 + - 198.41.128.0/17 + - 162.158.0.0/15 + - 104.16.0.0/13 + - 104.24.0.0/14 + - 172.64.0.0/13 + - 131.0.72.0/22 +v6: + - 2400:cb00::/32 + - 2606:4700::/32 + - 2803:f800::/32 + - 2405:b500::/32 + - 2405:8100::/32 + - 2a06:98c0::/29 + - 2c0f:f248::/32 diff --git a/config/deploy.yml b/config/deploy.yml index 98cf164..12b7339 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -16,22 +16,41 @@ servers: # - 192.168.0.1 # cmd: bin/jobs -# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. -# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# TLS between Cloudflare and this origin uses a Cloudflare Origin Certificate +# (Cloudflare dashboard: SSL/TLS -> Origin Server -> Create Certificate; set the +# zone's SSL/TLS mode to "Full (strict)"). It is trusted only by Cloudflare, +# which is the point: the server's firewall should admit 443 from Cloudflare's +# ranges only, so nothing else ever reaches this origin. Nothing is validated to +# issue it and it is valid for up to 15 years, so there is no renewal to break. # -# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. -# Let's Encrypt needs port 80 reachable at issue time and at every renewal; if a -# firewall admits only Cloudflare, use a Cloudflare Origin Certificate instead: -# ssl: -# certificate_pem: ORIGIN_CERT # add both to .kamal/secrets, read from files -# private_key_pem: ORIGIN_KEY -# forward_headers: true # ssl: stops forwarding X-Forwarded-For otherwise, -# # and every caller would look like Cloudflare to rate_limit -# ssl_redirect: false # Rails force_ssl already redirects on X-Forwarded-Proto +# These settings are per app, not per proxy: several apps on one server each +# bring their own certificate and kamal-proxy routes by host. +# +# Without Cloudflare, use Let's Encrypt instead. It needs port 80 open to the +# world at issue time and at every 60-day renewal: +# proxy: +# ssl: true +# host: api.nativeapptemplate.com proxy: - ssl: true + ssl: + # Both read from PEM files by .kamal/secrets. + certificate_pem: NATIVEAPPTEMPLATEAPI_ORIGIN_CERT + private_key_pem: NATIVEAPPTEMPLATEAPI_ORIGIN_KEY host: api.nativeapptemplate.com + # With ssl set, kamal-proxy stops forwarding X-Forwarded-For unless told to. + # Rails' rate_limit keys on request.remote_ip; without this every caller would + # look like the Cloudflare edge and share one bucket. config/cloudflare_ips.yml + # (trusted_proxies) is the other half: it lets Rails resolve past the edge. + forward_headers: true + + # kamal-proxy would otherwise answer every plain-HTTP request with a redirect, + # which loops while Cloudflare still reaches the origin over :80 (SSL/TLS mode + # "Flexible", or the few :80 requests "Full (strict)" still sends). Rails' + # force_ssl redirects on the scheme the visitor used (X-Forwarded-Proto), which + # kamal-proxy cannot see. + ssl_redirect: false + # Credentials for your image host. registry: # GitHub Container Registry. diff --git a/test/config/deploy_config_test.rb b/test/config/deploy_config_test.rb index 2240da5..83989ef 100644 --- a/test/config/deploy_config_test.rb +++ b/test/config/deploy_config_test.rb @@ -37,7 +37,8 @@ class DeployConfigTest < ActiveSupport::TestCase # .kamal/secrets; an unknown name aborts the deploy. test ".kamal/secrets defines every secret deploy.yml references" do defined = File.readlines(Rails.root.join(".kamal/secrets")).filter_map { |line| line[/\A([A-Z0-9_]+)=/, 1] } - referenced = DEPLOY.dig("env", "secret") + DEPLOY.dig("registry", "password") + DEPLOY.dig("accessories", "db", "env", "secret") + referenced = DEPLOY.dig("env", "secret") + DEPLOY.dig("registry", "password") + + DEPLOY.dig("accessories", "db", "env", "secret") + DEPLOY.dig("proxy", "ssl").values assert_empty referenced - defined end diff --git a/test/integration/trusted_proxies_test.rb b/test/integration/trusted_proxies_test.rb new file mode 100644 index 0000000..2f9dc94 --- /dev/null +++ b/test/integration/trusted_proxies_test.rb @@ -0,0 +1,118 @@ +require "test_helper" + +# The throttles must count the caller, not the Cloudflare edge in front of this +# origin (config/application.rb, config/cloudflare_ips.yml). Rails' rate_limit +# keys on request.remote_ip, which trusted_proxies resolves past the edge. +# +# Asserted through 429s rather than by reading the config back: with the config +# absent everything still "works", it just counts the wrong party. What breaks +# is who gets locked out. +class TrustedProxiesTest < ActionDispatch::IntegrationTest + # Two Cloudflare edge addresses in different /24s of 172.64.0.0/13. + TOKYO_EDGE = "172.64.213.30".freeze + OTHER_EDGE = "172.71.24.21".freeze + + # Two callers, neither of them a Cloudflare address. + ALICE = "118.241.68.237".freeze + BOB = "203.0.113.9".freeze + + SIGN_IN = "/shopkeeper_auth/sign_in".freeze + + # Cloudflare sets X-Forwarded-For to the caller; kamal-proxy appends the + # address it received the connection from, which is Cloudflare's. + # + # The email is unique per attempt on purpose: `logins/email` also throttles + # five per twenty seconds, and reusing one would trip that rule instead. + def attempt_sign_in(caller_ip:, edge:) + post SIGN_IN, + params: {email: "#{SecureRandom.hex(6)}@example.com", password: "wrong-password"}, + headers: {"HTTP_X_FORWARDED_FOR" => "#{caller_ip}, #{edge}", + "REMOTE_ADDR" => edge} + response.status + end + + # `logins/ip` allows five attempts per twenty seconds (ShopkeeperAuth::SessionsController). + # Counted against the edge, five sign-ins from anyone behind it lock out everybody else behind it. + test "two callers behind one Cloudflare edge do not share a login bucket" do + 5.times { assert_not_equal 429, attempt_sign_in(caller_ip: ALICE, edge: TOKYO_EDGE) } + + assert_not_equal 429, attempt_sign_in(caller_ip: BOB, edge: TOKYO_EDGE), + "Bob's first attempt was refused because Alice used the allowance behind the same edge" + end + + # Brute force protection has to survive the attacker being routed through + # different edges, which Cloudflare does for them. + test "one caller is throttled even when routed through two Cloudflare edges" do + 3.times { attempt_sign_in(caller_ip: ALICE, edge: TOKYO_EDGE) } + 2.times { attempt_sign_in(caller_ip: ALICE, edge: OTHER_EDGE) } + + assert_equal 429, attempt_sign_in(caller_ip: ALICE, edge: OTHER_EDGE), + "the sixth attempt from one caller must be refused however Cloudflare routed it" + end + + # rate_limit's default `by:` is request.remote_ip, which RemoteIp resolves with + # trusted_proxies. Rack's own Request#ip ignores trusted_proxies and stops at the edge. + test "the RemoteIp middleware resolves past the Cloudflare edge to the caller" do + request = ActionDispatch::Request.new("REMOTE_ADDR" => TOKYO_EDGE, "HTTP_X_FORWARDED_FOR" => "#{ALICE}, #{TOKYO_EDGE}") + trusted = Rails.application.config.action_dispatch.trusted_proxies + + assert_equal ALICE, ActionDispatch::RemoteIp::GetIp.new(request, false, trusted).to_s + assert_equal TOKYO_EDGE, request.ip, "Rack's own ip still stops at the edge" + end + + # Rails already trusts the private range 172.16.0.0/12 (172.16 to 172.31). + # Cloudflare's 172.64.0.0/13 (172.64 to 172.71) looks covered and is not. + test "Cloudflare's 172.64.0.0/13 is trusted and Rails' private 172.16.0.0/12 does not cover it" do + trusted = Rails.application.config.action_dispatch.trusted_proxies + + assert trusted.any? { |proxy| proxy.include?(TOKYO_EDGE) }, "a Cloudflare edge address must be trusted" + assert_not IPAddr.new("172.16.0.0/12").include?(TOKYO_EDGE), + "if this ever passes, the private range grew and the near miss stopped being one" + end + + # `trusted_proxies =` replaces rather than appends, and kamal-proxy reaches the + # container over a private Docker address, so dropping the defaults would + # break the hop this exists to see past. + test "the private and loopback defaults are still trusted" do + trusted = Rails.application.config.action_dispatch.trusted_proxies + + ["127.0.0.1", "10.1.2.3", "192.168.1.1", "172.17.0.2"].each do |address| + assert trusted.any? { |proxy| proxy.include?(address) }, + "#{address} is a default Rails trusts, and replacing the list would have dropped it" + end + end + + # The half that lives outside the app. kamal-proxy stops forwarding + # X-Forwarded-For when `ssl:` is set, so without this the caller's address never + # reaches Rails and every test above still passes while production throttles Cloudflare. + test "kamal-proxy is told to forward headers, or everything above this is inert" do + proxy = YAML.safe_load_file(Rails.root.join("config/deploy.yml")).fetch("proxy") + + assert proxy["ssl"], "if ssl is ever removed, re-read kamal's default: forward_headers flips with it" + assert_equal true, proxy["forward_headers"] + end + + # Kamal docs: "By default, kamal-proxy will redirect all HTTP requests to HTTPS + # when SSL is enabled." While Cloudflare reaches the origin over :80 (SSL/TLS + # mode Flexible, or the residual :80 traffic under Full (strict)) that is an + # infinite redirect. Asserted as false, not merely "not true": with the key + # omitted kamal sends no flag and kamal-proxy applies its default. + test "kamal-proxy is told not to redirect HTTP to HTTPS" do + proxy = YAML.safe_load_file(Rails.root.join("config/deploy.yml")).fetch("proxy") + + assert_equal false, proxy["ssl_redirect"], "absent is not false here" + end + + # `ssl: true` would have kamal-proxy run an ACME challenge on :80, which a + # Cloudflare-only firewall blocks and "Always Use HTTPS" redirects away; it + # would fail at renewal, not at deploy time. + test "TLS uses the Cloudflare Origin certificate from secrets" do + ssl = YAML.safe_load_file(Rails.root.join("config/deploy.yml")).fetch("proxy").fetch("ssl") + secrets = File.read(Rails.root.join(".kamal/secrets")) + + assert_equal "NATIVEAPPTEMPLATEAPI_ORIGIN_CERT", ssl["certificate_pem"] + assert_equal "NATIVEAPPTEMPLATEAPI_ORIGIN_KEY", ssl["private_key_pem"] + assert_match(/^NATIVEAPPTEMPLATEAPI_ORIGIN_CERT=\$\(cat /, secrets, "the certificate must be read from a file") + assert_match(/^NATIVEAPPTEMPLATEAPI_ORIGIN_KEY=\$\(cat /, secrets, "the key must be read from a file") + end +end From 7a4c20e30134782570ec9749234967a00682bf57 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 10 Oct 2026 16:28:50 +0900 Subject: [PATCH 3/3] Build images for amd64 by default Generated apps are expected on x86 servers (Vultr Tokyo) more often than on the ARM Hetzner box, and amd64 is Kamal's own default. The comment explains the QEMU cost from Apple Silicon and when to pick arm64; a test pins the choice so a server move updates it deliberately. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WyUzNEPWyhjjWrBcSaXSW4 --- config/deploy.yml | 8 +++++--- test/config/deploy_config_test.rb | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/config/deploy.yml b/config/deploy.yml index 12b7339..2cb85cb 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -64,9 +64,11 @@ registry: # Configure builder setup. builder: - # ARM servers (e.g. Hetzner CAX, AWS Graviton) build natively on Apple Silicon. - # Use amd64 for x86 servers. - arch: arm64 + # x86 servers (most VPS plans). From an Apple Silicon Mac this is a QEMU build, + # several times slower than native; the Dockerfile's `bootsnap precompile -j 1` + # works around a QEMU bug. ARM servers (Hetzner CAX, AWS Graviton) take + # `arm64`, which Apple Silicon builds natively. + arch: amd64 # Inject ENV variables into containers (secrets come from .kamal/secrets). env: diff --git a/test/config/deploy_config_test.rb b/test/config/deploy_config_test.rb index 83989ef..ea5e73a 100644 --- a/test/config/deploy_config_test.rb +++ b/test/config/deploy_config_test.rb @@ -73,6 +73,13 @@ class DeployConfigTest < ActiveSupport::TestCase assert_match %r{\A127\.0\.0\.1:\d+:5432\z}, DEPLOY.dig("accessories", "db", "port") end + # The image must match the server's CPU, or the container dies with "exec + # format error" after a successful push. Kamal's default and most VPS plans are + # x86; a deliberate change to arm64 for an ARM box should update this too. + test "image is built for x86 servers" do + assert_equal "amd64", DEPLOY.dig("builder", "arch") + end + test "Dockerfile builds the Ruby in .ruby-version" do ruby_version = Rails.root.join(".ruby-version").read.strip dockerfile = Rails.root.join("Dockerfile").read