Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions .kamal/secrets
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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

# 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)
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <env>`) 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)
Expand Down Expand Up @@ -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 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=<service>-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

Expand Down
74 changes: 74 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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=<value from config/credentials/production.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"]
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
33 changes: 33 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -488,6 +519,7 @@ DEPENDENCIES
image_processing (~> 2.0)
importmap-rails
jsonapi-serializer
kamal
madmin (~> 3.1)
mailbin
minitest-mock
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,35 @@ 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. 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. 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
```

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.
Expand Down
2 changes: 1 addition & 1 deletion bin/docker-entrypoint
Original file line number Diff line number Diff line change
@@ -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

Expand Down
16 changes: 16 additions & 0 deletions bin/kamal
Original file line number Diff line number Diff line change
@@ -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")
16 changes: 0 additions & 16 deletions bin/render-build.sh

This file was deleted.

Loading
Loading