docs/plans/5.4-spree-starter-and-create-spree-app.md
Status: Done Target: Spree 5.4 (immediate, pre-6.0) Depends on: None Author: Damian + Claude Last updated: 2026-03-18
Replace the monorepo server/ directory with an independent spree/spree-starter GitHub template repository that serves as the single Rails app for all contexts: user projects, core development, CI, and Docker image releases. Update create-spree-app to always include backend/ (cloned from spree-starter) alongside the storefront, and add a spree eject CLI command to switch from prebuilt Docker images to local builds. Simplify the release pipeline by eliminating the multi-Gemfile / multi-Dockerfile approach in favor of a single Gemfile with a SPREE_PATH env var conditional and gems published to RubyGems.
server/ is not portable. The monorepo's server/Gemfile uses path: "../spree" and its Dockerfile copies from ../spree/. This can't be dropped into a standalone project.
Two Gemfiles, two Dockerfiles. Gemfile (dev, path refs) vs Gemfile.release (production, local gem server) and Dockerfile (dev, copies monorepo) vs Dockerfile.release (3-stage gem compilation). Every config change must be synced across both.
create-spree-app produces an uncustomizable backend. The generated project uses a prebuilt Docker image. Users who need to add a gem, override a controller, or add a migration must figure out from scratch how to set up a Rails app that mounts Spree. This is the majority of real users.
No path from "trying Spree" to "building with Spree." The quick-start experience is good, but there's no guided transition to real development. Users hit a wall.
6.0 adds another app. When the admin panel becomes a separate Node app, the generated project needs apps/admin/ alongside apps/storefront/. The architecture should accommodate this now.
spree/spree-starter is the single source of truth for the Rails app. Used by create-spree-app, core developers, CI, and Docker image builds. The monorepo server/ directory is deleted.SPREE_PATH conditional. SPREE_PATH set → local gems (core dev). Unset → published gems from RubyGems (users, CI, release).Gemfile.lock in spree-starter. Lock files with path refs are useless for users; lock files with version refs are useless for core devs. Each context generates its own.Gemfile.common and Gemfile.release are eliminated. One Gemfile.Dockerfile.release is eliminated. One Dockerfile.backend/ directory name in generated projects (not server/).backend/ is always included in create-spree-app output. Not optional.docker-compose.yml uses ghcr.io/spree/spree:latest. Users run spree eject to switch to building from local backend/.spree-starter is a GitHub template repository. Users can also "Use this template" from GitHub without create-spree-app.spree-starter/
├── Gemfile # single Gemfile with SPREE_PATH conditional
├── Dockerfile # standard Rails multi-stage build
├── Procfile.dev # foreman: web + worker + css watchers
├── config.ru
├── Rakefile
├── .env.example # documented env vars
├── .gitignore # includes Gemfile.lock
├── bin/
│ ├── setup # local dev setup script
│ ├── dev # foreman launcher
│ ├── docker-entrypoint # container entrypoint
│ ├── rails, rake
│ └── ...
├── config/
│ ├── application.rb
│ ├── database.yml
│ ├── puma.rb
│ ├── routes.rb
│ ├── storage.yml
│ ├── cable.yml
│ ├── importmap.rb
│ ├── environments/
│ ├── initializers/
│ │ ├── spree.rb
│ │ ├── devise.rb
│ │ └── sentry.rb
│ └── locales/
├── db/
│ ├── migrate/ # copied from engines via rake
│ ├── schema.rb
│ └── seeds.rb
├── app/
│ ├── models/spree/
│ │ ├── user.rb
│ │ └── admin_user.rb
│ ├── controllers/
│ ├── views/
│ ├── javascript/
│ └── assets/
├── lib/
│ └── spree/
│ └── authentication_helpers.rb
└── public/
source "https://rubygems.org"
ruby file: ".ruby-version"
# Spree Commerce
spree_path = ENV["SPREE_PATH"]
if spree_path
gem "spree", path: "#{spree_path}/spree"
gem "spree_emails", path: "#{spree_path}/spree/emails"
gem "spree_admin", path: "#{spree_path}/spree/admin"
else
gem "spree", "~> 5.4"
gem "spree_emails", "~> 5.4"
gem "spree_admin", "~> 5.4"
end
# Extensions
gem "spree_stripe", github: "spree/spree_stripe"
gem "spree_i18n"
# Rails & Infrastructure
gem "rails", "~> 8.1.2"
gem "pg", "~> 1.1"
gem "puma", ">= 5.0"
gem "propshaft"
gem "turbo-rails"
gem "stimulus-rails"
gem "tailwindcss-rails", ">= 4"
gem "redis", ">= 4.0.1"
gem "sidekiq"
gem "devise"
gem "image_processing", "~> 1.2"
gem "aws-sdk-s3", require: false
gem "sentry-ruby"
gem "sentry-rails"
gem "lograge"
gem "bootsnap", require: false
gem "thruster", require: false
group :development do
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
gem "bundler-audit"
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
gem "letter_opener"
gem "listen", "~> 3.3"
end
group :test do
gem "simplecov-cobertura", require: false
end
group :development, :test do
gem "spree_dev_tools", require: false
end
ARG RUBY_VERSION=4.0.1
FROM ruby:${RUBY_VERSION}-slim AS base
WORKDIR /rails
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
curl libjemalloc2 libvips postgresql-client && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development:test"
FROM base AS build
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential git libpq-dev node-gyp pkg-config python-is-python3 && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
COPY Gemfile ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache \
"${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
COPY . .
RUN bundle exec bootsnap precompile app/ lib/ && \
SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
FROM base
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
chown -R rails:rails /rails
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
USER 1000:1000
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 3000
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
No gem-compilation stage. No local gem server. Gems come from RubyGems.
Note: Gemfile.lock is not in the repository. It is generated during bundle install in the Dockerfile (or locally by users/CI). The Dockerfile runs COPY Gemfile ./ without a lock file — Bundler resolves fresh, and BUNDLE_DEPLOYMENT=1 ensures the resolved versions are frozen for the remainder of the build.
my-store/
├── docker-compose.yml # prebuilt image (quick start)
├── docker-compose.dev.yml # build from ./backend (after eject)
├── .env
├── package.json
├── README.md
├── .gitignore
├── backend/ # git clone spree/spree-starter
│ ├── Gemfile
│ ├── Dockerfile
│ ├── config/
│ ├── app/
│ └── ...
└── apps/
└── storefront/ # git clone spree/storefront
6.0 addition:
└── apps/
├── storefront/
└── admin/ # git clone spree/admin
Environment variables match the documented conventions in docs/developer/deployment/docker.mdx and docs/developer/deployment/environment_variables.mdx:
x-app: &app
image: ghcr.io/spree/spree:${SPREE_VERSION_TAG:-latest}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file: .env
environment: &env
DATABASE_URL: postgres://postgres@postgres:5432/spree_production
REDIS_URL: redis://redis:6379/0
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false"
SMTP_HOST: mailpit
SMTP_PORT: "1025"
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_HOST_AUTH_METHOD: trust
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 5s
retries: 5
mailpit:
image: axllent/mailpit
ports:
- "8025:8025"
- "1025:1025"
web:
<<: *app
ports:
- "${PORT:-3000}:3000"
healthcheck:
test: curl -f http://localhost:3000/up || exit 1
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
worker:
<<: *app
command: bundle exec sidekiq
volumes:
postgres_data:
redis_data:
Key alignment with documented env vars:
DATABASE_URL — full Postgres URL (not separate DATABASE_HOST)REDIS_URL — as documentedSECRET_KEY_BASE — loaded from .env (generated by create-spree-app)RAILS_FORCE_SSL / RAILS_ASSUME_SSL — "false" for local dev (docs say default is true in production)SMTP_HOST / SMTP_PORT — mailpit for local dev (docs: "set SMTP_HOST to enable email delivery")PORT — documented as web server port (not SPREE_PORT)Identical to docker-compose.yml except the x-app anchor uses build: instead of image::
x-app: &app
build:
context: ./backend
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file: .env
environment: &env
DATABASE_URL: postgres://postgres@postgres:5432/spree_production
REDIS_URL: redis://redis:6379/0
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false"
SMTP_HOST: mailpit
SMTP_PORT: "1025"
# ... rest identical to docker-compose.yml
SECRET_KEY_BASE=<generated-128-char-hex>
PORT=3000
SECRET_KEY_BASE is generated via crypto.randomBytes(64).toString('hex'). PORT matches the documented env var name. All other env vars (DATABASE_URL, REDIS_URL, SMTP_*, RAILS_*) are set in docker-compose.yml directly since they're infrastructure-specific, not secrets.
spree eject CLI CommandNew command in @spree/cli:
// packages/cli/src/commands/eject.ts
export function registerEjectCommand(program: Command) {
program
.command('eject')
.description('Switch from prebuilt Docker image to local backend builds')
.action(async () => {
const ctx = detectProject(process.cwd())
// 1. Check backend/ exists
if (!existsSync(join(ctx.projectDir, 'backend'))) {
log.error('No backend/ directory found.')
process.exit(1)
}
// 2. Copy docker-compose.dev.yml → docker-compose.yml
copyFileSync(
join(ctx.projectDir, 'docker-compose.dev.yml'),
join(ctx.projectDir, 'docker-compose.yml')
)
// 3. Rebuild
await dockerCompose(['build'], ctx.projectDir)
// 4. Recreate containers
await dockerCompose(['up', '-d'], ctx.projectDir)
log.success('Ejected! Backend now builds from ./backend')
log.info('Edit backend/Gemfile, backend/config/, backend/app/ to customize.')
log.info('Run `npx spree dev` to restart with your changes.')
})
}
The monorepo root package.json gets a server:setup script that clones spree-starter into server/ and creates a .env file pointing SPREE_PATH to the local gems:
{
"scripts": {
"server:setup": "git clone --depth 1 https://github.com/spree/spree-starter.git server && rm -rf server/.git && echo 'SPREE_PATH=..' > server/.env && cd server && bundle install && bin/rails db:prepare",
"server:dev": "cd server && bin/dev",
"server:up": "docker compose up -d",
"server:down": "docker compose down",
"server:console": "cd server && bin/rails c",
"dev": "docker compose up -d && turbo dev"
}
}
This gives core devs the familiar server/ directory, but it's not checked into the monorepo — it's .gitignored and cloned on setup.
# Setup (one-time)
pnpm server:setup # clones spree-starter, creates .env, bundle install, db:prepare
# Development
pnpm server:dev # runs Rails + Sidekiq + CSS watchers natively
# or
pnpm dev # Docker infra + turbo dev for all packages
# Directory layout (monorepo)
spree/
├── spree/ # gems (core, api, admin, emails)
├── packages/ # SDKs, CLI, etc.
├── server/ # ← git clone of spree-starter (.gitignored)
│ ├── .env # ← auto-generated: SPREE_PATH=..
│ ├── Gemfile # uses SPREE_PATH conditional → local gems
│ └── ...
└── docker-compose.yml
The monorepo .gitignore adds server/ since it's now a generated artifact.
Docker build in monorepo context: The root docker-compose.yml does NOT use SPREE_PATH for Docker builds — Docker can't access paths outside the build context. Instead, the monorepo Dockerfile (which may differ slightly from spree-starter's) copies the local gems into the image. However, the simpler approach for core development is to not use Docker for the Rails app at all — run server/bin/dev natively (with SPREE_PATH=.. in .env) and only use Docker for Postgres, Redis, and Mailpit. This is faster for gem iteration anyway.
# Root docker-compose.yml (monorepo — infrastructure only)
services:
postgres:
image: postgres:17-alpine
# ...
redis:
image: redis:7-alpine
# ...
mailpit:
image: axllent/mailpit
# ...
# No web/worker services — run Rails natively via server/bin/dev
Core devs who want the full Docker experience can use the existing server/Dockerfile approach (which copies ../spree/) as an escape hatch, but the primary development workflow is native Rails + Docker infrastructure.
Current (complex):
server/Dockerfile.release → 3-stage build
Stage 1: gem build *.gemspec → /gems/
Stage 2: Gemfile.release + local gem server → frozen Gemfile
Stage 3: runtime
New (standard):
# In spree-starter repo (or CI clones it)
cd spree-starter
bundle update spree spree_core spree_api spree_emails spree_admin
git add Gemfile.lock
git commit -m "Update Spree to 5.4.1"
git push
# Build and push
docker build -t ghcr.io/spree/spree:5.4.1 .
docker push ghcr.io/spree/spree:5.4.1
docker tag ghcr.io/spree/spree:5.4.1 ghcr.io/spree/spree:latest
docker push ghcr.io/spree/spree:latest
For release builds, Gemfile.lock IS committed (on a release branch or tag) so the Docker build is deterministic.
.github/workflows/release.yml — Docker release job changes:
release-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: spree/spree-starter
ref: main
- name: Update Spree gems
run: |
bundle update spree spree_core spree_api spree_emails spree_admin
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/spree/spree:${{ github.ref_name }}
No more multi-platform gem compilation stages. Standard Docker build.
Monorepo tests.yml — Backend test job changes:
Tests continue to run in the monorepo against the gems directly (they test the gems, not the server app). The only change is removing any steps that reference server/.
spree-starter gets its own CI — basic smoke test:
# In spree/spree-starter/.github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bin/rails db:prepare
- run: bin/rails test # smoke test — boots app, runs migrations
- run: bundle exec brakeman --no-pager
- run: bundle audit check --update
spree/spree-starter on GitHub as a template repositoryserver/ as the starting pointGemfile + Gemfile.common + Gemfile.release with single Gemfile using SPREE_PATH conditionalDockerfile + Dockerfile.release with single Dockerfile (standard Rails build)Gemfile.lock from repo, add to .gitignore.env.example documenting all env varsREADME.md with getting started, customization, and core dev workflowserver to project root (it IS the project now)bundle install, bin/rails db:prepare, bin/rails server — app boots and serves APIbackend/ scaffolding step — git clone --depth 1 spree/spree-starter into backend/docker-compose.dev.yml template generationdocker-compose.yml template (no changes needed — already uses prebuilt image)README.md with eject instructionspackage.json scripts (add eject script)npx create-spree-app my-store produces working project with backend/ and apps/storefront/spree eject to CLIeject command in @spree/clisrc/index.tsnpx spree eject switches compose file, rebuilds, restartsserver/ directory from git (delete tracked files)server/ to monorepo .gitignorepackage.json — add server:setup (clone + bundle install + db:prepare), server:dev, server:console scripts; update existing server:up, server:downdocker-compose.yml for infrastructure-only (Postgres, Redis, Mailpit) — no web/worker services.github/workflows/release.yml Docker release jobCLAUDE.md monorepo structure tabledocs/developer/deployment/environment_variables.mdx. Use DATABASE_URL, REDIS_URL, SECRET_KEY_BASE, PORT, SMTP_HOST, SMTP_PORT, RAILS_FORCE_SSL, RAILS_ASSUME_SSL, etc. Do not invent new names (e.g., don't use SPREE_PORT — use PORT; don't use DATABASE_HOST in Docker — use DATABASE_URL).server/ that aren't also tracked for spree-starter migration. Any new initializer, migration, or config change in server/ must be noted for Phase 1.Gemfile.release or Gemfile.common entries. Add to the main Gemfile — it will become the single Gemfile.server/ paths in new CI workflows. The directory will be removed.backend/ (not server/) when discussing the generated project directory in docs, comments, or UI strings.bin/setup clones spree-starter into server/ (gitignored) and creates .env with SPREE_PATH=... Root docker-compose.yml continues to build from ./server.Gemfile.lock for release builds. The Dockerfile runs bundle install without a lock file (fresh resolve). For release determinism, should we:
Gemfile.lock on a release branch/tag in spree-starter before building, orbundle install with ~> 5.4 pinning is deterministic enough (gems are immutable once published)?Leaning toward (a) — commit lock file on release tags only.
spree-starter versioning. Should spree-starter track Spree versions (tag v5.4.1 matching the gem) or have its own version? Leaning toward matching Spree versions for clarity.
Migrations in spree-starter. Currently server/db/migrate/ has 134 migrations copied from engines. Should spree-starter:
rails spree:install:migrations on setup (always fresh, but extra step)?Leaning toward (b) with the setup script handling it automatically.
spree/spree-starter repo (to be wiped and rebuilt)server/ directory in monorepo (source material, to be deleted)create-spree-app: packages/create-spree-app/@spree/cli: packages/cli/docs/developer/deployment/docker.mdx — canonical Docker compose example and env vars (must stay aligned)docs/developer/deployment/environment_variables.mdx — full env var reference (authoritative names)Dockerfile.release 3-stage build: server/Dockerfile.release (to be eliminated)packages/create-spree-app/src/storefront.tsdocs/plans/6.0-admin-spa.md (admin as separate Node app in apps/admin/)