Back to Spree

Spree Starter & create-spree-app Overhaul

docs/plans/5.4-spree-starter-and-create-spree-app.md

5.4.222.6 KB
Original Source

Spree Starter & create-spree-app Overhaul

Status: Done Target: Spree 5.4 (immediate, pre-6.0) Depends on: None Author: Damian + Claude Last updated: 2026-03-18

Summary

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.

Problem

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Key Decisions (do not deviate without discussion)

  • 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.
  • Single Gemfile with SPREE_PATH conditional. SPREE_PATH set → local gems (core dev). Unset → published gems from RubyGems (users, CI, release).
  • No 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.
  • Single Dockerfile. Standard Rails multi-stage build. Gems come from RubyGems. No gem-compilation stage.
  • 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.
  • Prebuilt image by default. 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.

Design Details

spree-starter Repository Structure

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/

Gemfile

ruby
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

Dockerfile

dockerfile
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.

create-spree-app Generated Project

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

docker-compose.yml (default — prebuilt image)

Environment variables match the documented conventions in docs/developer/deployment/docker.mdx and docs/developer/deployment/environment_variables.mdx:

yaml
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 documented
  • SECRET_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)
  • Health check intervals match docker.mdx examples

docker-compose.dev.yml (after eject — local build)

Identical to docker-compose.yml except the x-app anchor uses build: instead of image::

yaml
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

.env (generated by create-spree-app)

bash
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 Command

New command in @spree/cli:

typescript
// 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.')
    })
}

Monorepo Development Workflow (after server/ removal)

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:

json
{
  "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.

bash
# 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.

yaml
# 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.

Release Pipeline

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):

bash
# 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.

CI Workflow Changes

.github/workflows/release.yml — Docker release job changes:

yaml
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:

yaml
# 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

Migration Path

Phase 1: Create spree-starter repository

  1. Create spree/spree-starter on GitHub as a template repository
  2. Copy contents from server/ as the starting point
  3. Replace Gemfile + Gemfile.common + Gemfile.release with single Gemfile using SPREE_PATH conditional
  4. Replace Dockerfile + Dockerfile.release with single Dockerfile (standard Rails build)
  5. Remove Gemfile.lock from repo, add to .gitignore
  6. Add .env.example documenting all env vars
  7. Update README.md with getting started, customization, and core dev workflow
  8. Add CI workflow (smoke test + security scans)
  9. Rename directory references from server to project root (it IS the project now)
  10. Test: clone fresh, bundle install, bin/rails db:prepare, bin/rails server — app boots and serves API

Phase 2: Update create-spree-app

  1. Add backend/ scaffolding step — git clone --depth 1 spree/spree-starter into backend/
  2. Add docker-compose.dev.yml template generation
  3. Update docker-compose.yml template (no changes needed — already uses prebuilt image)
  4. Update generated README.md with eject instructions
  5. Update generated package.json scripts (add eject script)
  6. Update prompts: remove backend-only/full-stack choice (always full-stack with backend included)
  7. Test: npx create-spree-app my-store produces working project with backend/ and apps/storefront/

Phase 3: Add spree eject to CLI

  1. Implement eject command in @spree/cli
  2. Add to command registration in src/index.ts
  3. Test: npx spree eject switches compose file, rebuilds, restarts

Phase 4: Update monorepo

  1. Remove server/ directory from git (delete tracked files)
  2. Add server/ to monorepo .gitignore
  3. Update root package.json — add server:setup (clone + bundle install + db:prepare), server:dev, server:console scripts; update existing server:up, server:down
  4. Update root docker-compose.yml for infrastructure-only (Postgres, Redis, Mailpit) — no web/worker services
  5. Update .github/workflows/release.yml Docker release job
  6. Update contributing docs
  7. Update CLAUDE.md monorepo structure table

Phase 5: Update release automation

  1. Update gem release workflow (unchanged — gems publish from monorepo)
  2. Update Docker release workflow to clone spree-starter, update gems, build, push
  3. Test full release cycle: tag monorepo → gems publish → Docker image builds from spree-starter with new gem versions

Constraints on Current Work

  • Environment variable names must match docs/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).
  • Stop adding files to 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.
  • Don't add new Gemfile.release or Gemfile.common entries. Add to the main Gemfile — it will become the single Gemfile.
  • Don't reference server/ paths in new CI workflows. The directory will be removed.
  • Use backend/ (not server/) when discussing the generated project directory in docs, comments, or UI strings.

Resolved Questions

  1. Monorepo root docker-compose.yml after server/ removal. bin/setup clones spree-starter into server/ (gitignored) and creates .env with SPREE_PATH=... Root docker-compose.yml continues to build from ./server.

Open Questions

  1. Gemfile.lock for release builds. The Dockerfile runs bundle install without a lock file (fresh resolve). For release determinism, should we:

    • (a) Generate and commit Gemfile.lock on a release branch/tag in spree-starter before building, or
    • (b) Accept that bundle install with ~> 5.4 pinning is deterministic enough (gems are immutable once published)?

    Leaning toward (a) — commit lock file on release tags only.

  2. 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.

  3. Migrations in spree-starter. Currently server/db/migrate/ has 134 migrations copied from engines. Should spree-starter:

    • (a) Include them in the repo (simpler for users, but stale on updates), or
    • (b) Generate them via rails spree:install:migrations on setup (always fresh, but extra step)?

    Leaning toward (b) with the setup script handling it automatically.

References

  • Existing spree/spree-starter repo (to be wiped and rebuilt)
  • Current server/ directory in monorepo (source material, to be deleted)
  • Current create-spree-app: packages/create-spree-app/
  • Current @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)
  • Current Dockerfile.release 3-stage build: server/Dockerfile.release (to be eliminated)
  • Storefront clone pattern: packages/create-spree-app/src/storefront.ts
  • Related: docs/plans/6.0-admin-spa.md (admin as separate Node app in apps/admin/)