Templates let users scaffold new projects with scdev create. A template is a GitHub repository or local directory that contains an .scdev/ configuration and optionally starter files.
scdev create myorg/my-template my-app # Any GitHub repo
scdev create express my-app # Shorthand for ScaleCommerce-DEV/scdev-template-express
scdev create ./local-dir my-app # Local directory (for development/testing)Tip
Install the scdev skill and Claude Code can drive the whole template-authoring workflow for you — picking images, writing config.yaml, scaffolding setup.just, testing the run, and iterating when something breaks.
npx skills add scalecommerce-dev/scdevThen start a Claude Code session and say something like "create an scdev template for Foo". The skill triggers automatically on template-authoring phrases and on any .scdev/config.yaml or setup.just question.
Every template has the same base structure:
my-template/
.scdev/
config.yaml # Container configuration (image, ports, volumes, etc.)
commands/
setup.just # Setup script (install deps, scaffold project)
README.md # Usage instructions
Beyond this, a template may or may not include app source files. This depends on the framework:
Include source files when there's no scaffolding command. Example: an Express template ships with app.js and package.json because Express has no create command - you just write files and install dependencies.
Don't include source files when the framework has its own scaffolding. Example: a Nuxt template ships only .scdev/ because nuxi init generates all app files. A Symfony template ships only .scdev/ because symfony new does the same. Including source files that the scaffolder also creates would cause conflicts.
After scdev create, the user's workflow is:
scdev create <template> my-app
cd my-app
scdev setupscdev setup runs the setup.just file which handles everything: starting containers, installing dependencies, scaffolding the project (if needed), and signaling that the app is ready to run.
Templates need a setup step because the container alone isn't enough. Dependencies must be installed, frameworks may need scaffolding, and the app needs to be configured before it can serve requests. Without a setup step, the container would start but have nothing to run.
The setup justfile runs on the host and uses scdev exec to run commands inside the container. This is important because scdev exec provides an interactive terminal - the user can respond to prompts from package managers and scaffolding tools. The container entrypoint has no terminal, so interactive prompts would crash there.
To learn how to write setup files, see Writing setup.just.
There's a circular dependency between the container and setup:
- The container must be running for
scdev execto work (setup needs the container) - But the app can't start until setup finishes (the container needs setup)
The .setup-complete marker file solves this. The container entrypoint checks for it:
command: >-
sh -c "
if [ ! -f .setup-complete ]; then
echo 'Waiting for setup... Run: scdev setup';
while [ ! -f .setup-complete ]; do sleep 2; done;
fi;
pnpm install && exec pnpm dev"- First start - no
.setup-completeexists. The container enters a wait loop, staying alive without crashing. Nowscdev execworks. - Setup runs - installs deps, scaffolds if needed, then
touch .setup-complete. The wait loop detects the marker and starts the app. - On restart -
.setup-completeexists, so the container skips the wait loop, runs dependency install (to pick up any new packages), and starts the app immediately.
The dependency install command (pnpm install, composer install, etc.) must be in the entrypoint so that restarting the container after adding a new package works without re-running setup.
The config defines your containers, routing, and sync settings:
version: 1
name: ${PROJECTDIR}
domain: ${PROJECTNAME}.${SCDEV_DOMAIN}
info: |
## ${PROJECTNAME}
Description of the project.
Run `scdev setup` to get started.
variables: # reusable values for ${VAR} substitution in this file
DB_PASSWORD: root
DB_NAME: ${PROJECTNAME}
shared: # connect shared services to project's docker network
router: true
mail: true # Mailpit - catch all outgoing email (SMTP at mail:1025)
db: true # Adminer - browse databases via web UI
redis: true # Redis Insights - browse Redis keys via web UI
environment: # env vars passed to ALL containers
APP_ENV: dev
services:
app:
image: node:22-alpine
command: >-
sh -c "corepack enable &&
if [ ! -f .setup-complete ]; then
echo 'Waiting for setup... Run: scdev setup';
while [ ! -f .setup-complete ]; do sleep 2; done;
fi;
pnpm install && exec pnpm dev"
working_dir: /app
volumes:
- ${PROJECTPATH}:/app
environment: # env vars passed to THIS container only
NODE_ENV: development
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
HOST: "0.0.0.0"
PORT: "3000"
DATABASE_URL: mysql://root:${DB_PASSWORD}@db:3306/${DB_NAME}
routing:
port: 3000
# domain: api.${PROJECTNAME}.${SCDEV_DOMAIN} # optional: custom domain for this service
db:
image: mysql:8.0
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
mutagen:
ignore:
- node_modules
- .pnpm-store
- .scdev
- .setup-completevariables define reusable ${VAR} placeholders that are substituted throughout the config file. They are not passed to containers. Use them to avoid duplicating values like database passwords across services.
environment (project-level) defines environment variables passed to ALL containers. services.<name>.environment (service-level) defines environment variables for that specific container only, and overrides project-level env vars with the same name.
Built-in variables are always available: ${PROJECTDIR}, ${PROJECTPATH}, ${PROJECTNAME}, ${SCDEV_DOMAIN}, ${SCDEV_HOME}, ${USER}, ${HOME}, plus all host environment variables. User-defined variables can reference built-in ones (e.g. DB_NAME: ${PROJECTNAME}_db).
Dev server binding: Dev servers typically listen on localhost by default, which isn't accessible from outside the container. Set HOST=0.0.0.0 (or the framework's equivalent) in the environment so the dev server binds to all interfaces.
Multi-service routing: Projects with multiple HTTP services (frontend + backend) can give each service its own domain using routing.domain. Only available for HTTP/HTTPS routing. Without it, all services share the project domain.
Volumes in the services.<name>.volumes list come in two forms:
Bind mounts map a host directory into the container. Your source code goes here - edits on the host are reflected in the container immediately (via Mutagen on macOS, direct mount on Linux).
volumes:
- ${PROJECTPATH}:/app # host directory -> container pathNamed volumes are persistent storage managed by Docker. Use them for data that should survive container recreation but doesn't belong on the host - database files, dependency directories, caches.
volumes:
- ${PROJECTPATH}:/app # bind mount: source code
- db_data:/var/lib/mysql # named volume: database files
- node_modules:/app/node_modules # named volume: dependencies (alternative to Mutagen ignore)How to tell them apart: if the left side starts with /, ./, ../, or ${ it's a bind mount. Otherwise it's a named volume.
Named volumes persist across scdev stop/scdev start and scdev down. They are only removed with scdev down -v. No top-level declaration needed - scdev discovers them automatically from the volume entries.
When to use named volumes vs Mutagen ignore for dependencies: Both approaches keep dependencies inside the container. Named volumes are explicit and work everywhere. Mutagen ignore is simpler (no extra volume entry) but only applies when Mutagen is active (macOS). For templates, prefer Mutagen ignore for Node.js node_modules since it's the standard scdev pattern.
Docker bind mounts on macOS are notoriously slow - operations like pnpm install or composer install that touch thousands of files can take 5-10x longer than native. scdev solves this by using Mutagen for fast bidirectional file sync between your host and a Docker volume. This happens automatically on macOS (on Linux, bind mounts are already fast, so Mutagen is not used).
The Mutagen ignore list controls which paths are not synced in either direction. Ignored paths exist only inside the container's volume. This is essential for dependencies like node_modules or vendor - they contain platform-specific binaries that must match the container's OS, and syncing thousands of dependency files would negate Mutagen's performance gains.
Add directories that should stay inside the container and not sync to the host:
mutagen:
ignore:
- node_modules # Native modules, platform-specific (Node.js)
- .pnpm-store # pnpm content-addressable store
- vendor # Composer dependencies (PHP)
- .scdev # scdev config (only needed on host)
- .setup-complete # Marker file (must persist in container volume)Add framework-specific build artifacts:
- Nuxt:
.nuxt,.output - Next.js:
.next - Symfony:
var(cache, logs)
Critical: .setup-complete MUST be in the ignore list. Since it's ignored, it persists in the container's Mutagen volume independently of the host. If it were synced, it could be deleted on one side and propagate to the other, breaking the setup state.
Templates can include commands in .scdev/commands/. Each .just file becomes a scdev subcommand:
.scdev/commands/
setup.just -> scdev setup
test.just -> scdev test
seed.just -> scdev seed
Commands are written as just recipes. Just is a command runner (think make without the build system baggage). A justfile can have multiple recipes, arguments, dependencies between recipes, conditional logic, and more. See the just documentation for the full syntax.
When you run scdev <command>, scdev looks for .scdev/commands/<command>.just and executes it. If the justfile has multiple recipes, you can run a specific one with scdev <command> <recipe>. If no recipe is given, just runs the default recipe (if defined). For example:
# .scdev/commands/test.just
default: unit # scdev test -> runs unit tests
unit: # scdev test unit
scdev exec app pnpm test
watch: # scdev test watch
scdev exec app pnpm test --watch
e2e: # scdev test e2e
scdev exec app pnpm test:e2eTo wrap CLIs like bin/console cache:clear or artisan migrate:fresh, declare a recipe named after the file. scdev auto-prepends it so args with colons pass through as recipe parameters instead of being parsed as just's module path:
# .scdev/commands/console.just
console *args:
scdev exec app php bin/console {{args}}Now scdev console cache:clear -> bin/console cache:clear. Without a filename-matching recipe, the legacy behavior holds (first arg is the recipe name), so test.just above keeps working.
Templates typically include at least setup.just. You can add more commands for common tasks like running tests, seeding databases, or deploying. These commands are discoverable - scdev --help lists them, and agents can ls .scdev/commands/ to find them.
Justfiles run on the host, not inside the container. Use scdev exec app <command> to run things inside the container.
Setup often runs thousands of lines of command output (package managers installing deps, scaffolders writing files, compilers building assets). Plain @echo "Installing PHP extensions..." lines get buried in that noise and users lose track of which phase is running. Use @scdev step "<message>" instead - it prints two leading blank lines, a cyan ▶, and the message in bold, so each phase reads as a clear section header even when the surrounding output is a wall of text. Styling is stripped when stdout isn't a TTY, when NO_COLOR is set, or when the user has plain mode enabled in global config, so the same recipe works in logs and CI too.
# Description of what setup does
[no-exit-message]
default:
scdev start -q
@scdev step "Installing dependencies"
scdev exec app sh -c "your install commands here && touch .setup-complete"
@scdev step "Setup complete! App will start automatically."
scdev infoConventions:
scdev startgoes first - the container must be running beforescdev exectouch .setup-completegoes last in the exec - only after everything succeeds@scdev step "<msg>"for each top-level phase instead of@echo; reserve plain@echofor sub-detail lines that don't need to stand out- Keep echo ON for
scdev start,scdev exec, andscdev infoso the user sees what's running - Add
[no-exit-message]to suppress just's default exit message
Frameworks like Nuxt and Symfony have their own scaffolding commands (nuxi init, symfony new). These commands typically expect an empty directory, which conflicts with .scdev/ already being there.
There are two approaches depending on whether the scaffolding tool supports a force flag.
If the scaffolding command can run in a non-empty directory, scaffold directly in /app. This is the cleanest approach - no copying, no path issues.
On macOS with Mutagen, .scdev is in the ignore list so the container sees an essentially empty /app. On Linux with bind mounts, .scdev/ is visible but scaffolding tools just add their own files alongside it.
Example - Nuxt (nuxi init supports --force) - .scdev/commands/setup.just:
[no-exit-message]
default:
scdev start -q
@scdev step "Installing tools"
scdev exec app sh -c "corepack enable && apk add --no-cache git"
@scdev step "Scaffolding Nuxt project"
scdev exec app pnpm dlx nuxi@latest init . --packageManager pnpm --gitInit=false --force
@scdev step "Preparing Nuxt modules"
scdev exec app npx nuxi prepare
@scdev step "Approving native module builds"
scdev exec app pnpm approve-builds --all
@scdev step "Finalizing"
scdev exec app sh -c "echo '.setup-complete' >> .gitignore && touch .setup-complete"
@scdev step "Setup complete! App will start automatically."
scdev infoKey details:
nuxi init .scaffolds into the current directory, not a temp dir--forceallows a non-empty directorynpx nuxi prepareruns Nuxt module initialization, which may prompt to install missing dependencies (e.g.better-sqlite3for@nuxt/content). This runs interactively viascdev execso prompts work. Without this, the same prompts would fire in the container entrypoint where there's no terminal, crashing the container.pnpm approve-builds --allapproves native module build scripts after prepare (which may have installed new packages)echo '.setup-complete' >> .gitignoreappends our marker to the scaffolder's gitignoreCOREPACK_ENABLE_DOWNLOAD_PROMPTis set in config.yaml's environment, not repeated in each command
Some scaffolding tools have no force flag and strictly require an empty directory. In this case, scaffold in /tmp inside the container, then copy the files to /app.
This approach is safe for PHP because Composer's autoloader uses __DIR__ relative paths resolved at runtime. Moving vendor/ between directories works fine. It does NOT work reliably for Node.js/pnpm because pnpm uses a symlink-based content-addressable store with paths tied to the install location.
Example - Symfony (symfony new requires an empty directory) - .scdev/commands/setup.just:
[no-exit-message]
default:
scdev start -q
@scdev step "Installing dependencies"
scdev exec app apk add --no-cache bash
@scdev step "Installing Composer"
scdev exec app sh -c "wget -qO- https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer"
@scdev step "Installing Symfony CLI"
scdev exec app sh -c "wget https://get.symfony.com/cli/installer -O - 2>/dev/null | bash && cp \$HOME/.symfony5/bin/symfony /usr/local/bin/symfony"
@scdev step "Scaffolding Symfony project"
scdev exec app symfony new /tmp/app --no-git
@scdev step "Copying project files"
scdev exec app sh -c "cp -r /tmp/app/. /app/ && rm -rf /tmp/app && echo '.setup-complete' >> .gitignore && touch .setup-complete"
@scdev step "Setup complete! App will start automatically."
scdev infoKey details:
symfony new /tmp/app --no-gitscaffolds into a temp directory (Symfony has no--forcefor existing dirs)--no-gitskips git init, avoiding the need for git config in the container- Composer and Symfony CLI are installed to
/usr/local/binso they're available in subsequent exec calls cp -r /tmp/app/. /app/copies all files (including dotfiles) into the project directory. The.scdev/directory in/appis preserved because Symfony doesn't create one.- Composer and Symfony CLI are installed at runtime since
php:8.4-cli-alpinedoesn't include them
| Approach | Use when | Examples |
|---|---|---|
In-place with --force |
The scaffolding tool supports non-empty directories | Nuxt (nuxi init . --force) |
| /tmp + copy | The tool strictly requires an empty directory AND the ecosystem's dependency dir is portable | Symfony (symfony new), Laravel |
| No scaffolding needed | The template includes all source files | Express, static sites |
Rule of thumb: Prefer in-place scaffolding when possible. Only use the /tmp approach when the tool has no force flag, and only when the language ecosystem supports moving the dependency directory (PHP/Composer: yes, Node.js/pnpm: no).
Corepack prompt: Node.js ships with corepack but pnpm must be downloaded on first use. Suppress the confirmation prompt:
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 && corepack enableAlways export the variable so it applies to all subsequent commands in the same sh -c.
Native module build scripts: pnpm v10 blocks build scripts by default. After installing dependencies, run:
pnpm approve-builds --allThis approves all pending native modules (like better-sqlite3, esbuild, @parcel/watcher) non-interactively and triggers their build/download of prebuilt binaries. Run it AFTER pnpm install so there are packages to approve. It saves the approvals to package.json so they persist across reinstalls.
File watching: Use Node.js built-in --watch mode (Node 22+) for automatic restarts:
"scripts": {
"start": "node --watch app.js"
}Frameworks like Nuxt and Next.js have their own HMR - no extra config needed.
Runtime dependency prompts: Some Nuxt modules (like @nuxt/content) prompt to install missing dependencies at runtime. These prompts need a terminal which the container entrypoint doesn't have. Fix: run the framework's prepare step during setup (when scdev exec provides a terminal). For Nuxt: npx nuxi prepare.
Installing Composer: The php:*-cli-alpine images don't include Composer. Install it at runtime:
wget -qO- https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composerInstalling Symfony CLI: For the Symfony dev server and symfony new command:
apk add --no-cache bash
wget https://get.symfony.com/cli/installer -O - 2>/dev/null | bash
export PATH="$HOME/.symfony5/bin:$PATH"Dev server: Use the Symfony CLI server for full compatibility:
symfony server:start --no-tls --port=8000 --allow-all-ip--no-tls because scdev handles HTTPS via Traefik. --allow-all-ip binds to 0.0.0.0.
vendor/ portability: Unlike Node.js, PHP's vendor/ directory uses __DIR__ for path resolution at runtime. Copying vendor/ between directories (e.g. from /tmp/app to /app) works fine. This is why the /tmp scaffolding approach is safe for PHP but not for Node.js.
Raise memory_limit: the PHP CLI default (128 MB) OOMs on Symfony cache:clear post-install scripts, Composer dependency solving on large projects, and anything that loads the full container. Add a drop-in ini file once in setup and again guard it in the entrypoint so it survives container recreation:
printf 'memory_limit=-1\n' > /usr/local/etc/php/conf.d/zz-app.iniInstall missing PHP extensions: php:8.3-cli-alpine ships only a minimal set. Projects like Sylius, Shopware, and Akeneo need intl pdo_mysql gd bcmath opcache exif zip at minimum. Use install-php-extensions:
wget -qO /usr/local/bin/install-php-extensions \
https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions
chmod +x /usr/local/bin/install-php-extensions
install-php-extensions intl pdo_mysql gd bcmath opcache exif zipGuard with [ ! -f /usr/local/bin/install-php-extensions ] in the entrypoint so restarts skip the reinstall.
Trusted proxies (Symfony/Sylius/Laravel/Shopware): Traefik terminates HTTPS and forwards HTTP to the app. Without a trusted-proxy config, Symfony generates http:// URLs inside the HTTPS page - the browser blocks them as mixed content, the debug toolbar hangs on "Loading…", and admin login breaks. Set this in the app service environment::
environment:
SYMFONY_TRUSTED_PROXIES: private_ranges # Symfony 6.3+ shorthand
# Laravel equivalent uses TRUSTED_PROXIES=* for the TrustProxies middlewareThis is required for any PHP framework that builds absolute URLs while running behind a reverse proxy. It's the single most common runtime surprise on PHP framework templates.
Asset pipelines (Webpack Encore, Vite): Many PHP framework templates ship a package.json and build JS/CSS at install time (Sylius 2.x via Webpack Encore, Shopware 6 admin, most custom themes). Add Node to the app container and build assets in setup:
apk add --no-cache nodejs npm
npm install --no-audit --no-fund && npm run buildAlso add an idempotent rebuild in the entrypoint so scdev down && scdev start regenerates the bundle:
if [ -f package.json ] && [ ! -f public/build/shop/manifest.json ]; then
npm install --no-audit --no-fund && npm run build;
fiThis matters because public/build lives in mutagen.ignore (binary, regenerable), so it's lost on container recreation. Without the entrypoint rebuild, the first page load after a clean start 500s on a missing manifest.json.
Mailer DSN: for any Symfony/Sylius app, wire mail to Mailpit with MAILER_DSN: "smtp://mail:1025" (no auth, no TLS). For Laravel: MAIL_HOST=mail MAIL_PORT=1025 MAIL_ENCRYPTION=null.
For scaffold templates, don't include .gitignore, README.md, or any files that the scaffolding tool will create. The scaffolder's versions will take precedence. If you need scdev-specific entries (like .setup-complete), append them to the scaffolder's .gitignore after setup:
echo '.setup-complete' >> .gitignoreName your template repository scdev-template-<name>:
myorg/scdev-template-react -> scdev create myorg/scdev-template-react my-app
myorg/scdev-template-django -> scdev create myorg/scdev-template-django my-app
The ScaleCommerce-DEV org has a shorthand - bare names resolve to ScaleCommerce-DEV/scdev-template-<name>:
scdev create express -> ScaleCommerce-DEV/scdev-template-express
scdev create nuxt4 -> ScaleCommerce-DEV/scdev-template-nuxt4
During development, test your template locally by referencing the directory:
scdev create ./my-template test-app
cd test-app
scdev setupVerify:
scdev setupcompletes without errors- The app URL (
https://test-app.scalecommerce.site) loads correctly scdev restartworks (entrypoint picks up dependencies)- File changes are reflected (HMR or
--watchmode)
Browse all available templates on GitHub: ScaleCommerce-DEV repositories matching scdev-template-. Each template's README explains what it includes and how to use it.
Container crashes before setup runs.
The entrypoint must keep the container alive when .setup-complete doesn't exist. Use the wait loop pattern. Never use a command that can fail before setup (like pnpm start unconditionally).
Auxiliary container (db, queue, cache) exits immediately with sh: 0: Illegal option --.
The command: field in .scdev/config.yaml is wrapped in sh -c "<value>", not passed as a raw Docker CMD array. Flag-style args like command: --group_concat_max_len=320000 go straight to sh which rejects them. If you need to pass flags to the image's default binary (e.g. MariaDB, RabbitMQ), wrap them yourself: command: exec mariadbd --group_concat_max_len=320000 --sort_buffer_size=2M — or supply a config file via a volume mount instead.
scdev exec fails with "service not running".
The container crashed. Check scdev logs to see why. Common causes: the entrypoint command failed, missing .setup-complete wait loop, or a syntax error in the shell command.
Native modules fail with "Ignored build scripts".
pnpm v10 blocks build scripts by default. Run pnpm approve-builds --all after pnpm install to approve and rebuild them.
Framework prompts crash with "TTY initialization failed".
Some frameworks prompt interactively at runtime (e.g. @nuxt/content asking to install better-sqlite3). These prompts need a terminal which the container entrypoint doesn't have. Fix: trigger these checks during setup (when scdev exec provides a terminal) by running the framework's prepare step. For Nuxt: npx nuxi prepare.
Corepack asks "Do you want to continue?"
Set export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 before running corepack enable. Must be export so it applies to subsequent commands in the same sh -c.
Dev server not accessible via browser.
The dev server is listening on localhost inside the container. Set HOST=0.0.0.0 in the environment (Node.js) or use --allow-all-ip (Symfony CLI) so it binds to all interfaces.
Scaffolding tool complains about non-empty directory.
If the tool supports a force flag (--force), use it to scaffold in-place. If not (like symfony new), scaffold in /tmp and copy back. See "Handling framework scaffolding" above.
Changes on host not reflected in container (or vice versa).
Check the Mutagen ignore list. Ignored paths are not synced in either direction. Dependency directories (node_modules, vendor) should be ignored (they stay in the container). Source files should NOT be ignored.
Symfony/Sylius page renders but the Web Debug Toolbar is stuck on "Loading…", admin login bounces, or the browser console shows mixed-content errors.
The app is generating http:// URLs inside an HTTPS page because Symfony doesn't see that the outer request was HTTPS. Traefik terminates TLS and forwards plain HTTP to the app, but Symfony ignores the X-Forwarded-Proto header unless you trust the proxy. Set SYMFONY_TRUSTED_PROXIES: private_ranges in the service environment. Laravel equivalent: TRUSTED_PROXIES=*. See "PHP with Composer" above for details.
Storefront renders HTTP 500 with "Asset manifest file /app/public/build/*/manifest.json does not exist".
Webpack Encore (or similar bundler) assets haven't been built. Run scdev exec app npm run build once, then add npm install && npm run build to setup.just and an idempotent rebuild in the container entrypoint (see "PHP with Composer" above).
curl returns 200 but the browser sees errors.
HTTP status alone doesn't cover mixed-content blocks, CSP violations, or JS errors - those are browser-only failure modes. When finishing a template, verify in an actual browser (e.g. the chrome-devtools MCP tools) and check both the console and the network tab, not just the status code.