Make any repository preview-ready — ship a build-based docker-compose.preview.yml (works on any runner host) and a .spedy/preview.yml that declares the service and port Spedy routes to.
BYOC = Bring Your Own Compose. A preview boots your project's own Compose file — there is no Shopware/Magento template involved. You tell Spedy which service serves the preview, and the supervisor builds + runs your stack and routes it to a per-ticket URL.
To make a repository usable as a per-ticket live preview you need two things in the repo:
a Compose file that starts your stack — ideally a dedicated, build-based docker-compose.preview.yml (see below), and
a .spedy/preview.yml that declares the service + port Spedy routes to the outside.
This page covers both, plus the runner-host requirements so the preview actually comes up on your own infrastructure. For the UI side — connecting the repo, uploading database snapshots, and using the preview inside a ticket — see Preview Environments.
your-repo/├── docker-compose.preview.yml # the preview stack (recommended — see below)├── docker-compose.yml # your normal dev stack (used as fallback)├── .spedy/│ └── preview.yml # which service/port is the preview?└── … # your app code
.spedy/preview.yml:
# Tells the Spedy supervisor how this preview is exposed.service: app # name of the compose service Traefik routes toport: 3000 # container port that serves HTTP
When you click "Start preview" on a ticket, the runner:
clones the repo (on the ticket's feature branch),
picks the Compose file (prefers docker-compose.preview.yml — see Which Compose file),
reads .spedy/preview.yml,
builds + boots the stack and routes service/port under <orgSlug>--<ticket>.preview.<your-domain>.
So a dedicated docker-compose.preview.yml always wins over your dev compose. Ship one — it's the difference between a preview that runs anywhere and one that only runs if your host is configured just right (see Two ways to deliver the source).
The supervisor runs your preview's docker compose up against the host's Docker daemon (the preview containers are siblings of the supervisor, not nested inside it). That single fact decides how your source must reach the containers.
Ship a docker-compose.preview.yml that builds each app image and bakes the source in via the build context. The build context is read by the Docker client (inside the supervisor) and streamed to the daemon, so it does not depend on any host path. This preview runs on any runner host with zero extra host config.
# docker-compose.preview.ymlservices: app: build: context: . # Inline Dockerfile — no separate file needed. Bakes the source in. dockerfile_inline: | FROM node:22-alpine WORKDIR /app RUN corepack enable COPY . /app # Runtime still installs deps + runs the dev server, so anything you read # from the environment at runtime (public URLs, feature flags) just works. command: sh -c "corepack prepare pnpm@latest --activate && pnpm install && pnpm dev" environment: - NODE_ENV=development depends_on: - db db: image: postgres:16 environment: POSTGRES_PASSWORD: postgres volumes: - db_data:/var/lib/postgresql/datavolumes: db_data:
# .spedy/preview.ymlservice: appport: 3000
Notes:
No host bind mounts of source. That's the whole point — nothing under volumes: should be a host path (./src:/app). Named volumes (like db_data) are fine.
A service that needs a sibling directory at build time (e.g. a shared openapi/ folder one level up) can pull it in with build.additional_contexts:
Keep the runtime command (pnpm dev / next dev / vite) so environment-driven config is read at runtime, not frozen at build time, and you get hot reload while iterating. For frameworks that bake public env into the bundle at build time (e.g. Next.js NEXT_PUBLIC_*), running the dev server avoids having to pass every value as a build arg. On a small runner host a heavy app's dev server can be OOM-killed during on-demand compiles; if that happens, run a production build in the command instead (pnpm install && pnpm build && pnpm start, NODE_ENV=production) for a stable, lower-memory preview.
If you just point the preview at your normal dev docker-compose.yml (which bind-mounts source, e.g. ./api:/app), there's no build step — but the bind source is resolved on the host. The clone therefore has to live on a host path the daemon can see, identical inside and outside the supervisor container. See Runner host requirements. If that mount is missing you'll get an empty /app and a crash loop:
ERR_PNPM_NO_PKG_MANIFEST No package.json found in /app
Prefer the build-based model unless you specifically need live host editing.
Two ready-to-adapt starters — drop the two files into your repo root, tweak the
paths/versions, commit, and connect the repo. Both use the recommended
build-based model, so they run on any runner host.
Download shopware-preview.zip — built on the community dockware/dev image (PHP + nginx + MySQL + Shopware CLI), with your custom code baked in and theme-compile / cache-clear wired as post-run hooks.
# docker-compose.preview.ymlservices: shopware: build: context: . dockerfile_inline: | FROM dockware/dev:6.6.10.3 # Adjust to your repo layout (plugins, themes, config). COPY --chown=www-data:www-data custom/plugins /var/www/html/custom/plugins environment: - APP_ENV=dev
The all-in-one dockware image runs its own MySQL and doesn't expose an init-DB
hook, so an uploaded DB snapshot
can't be auto-imported into it. To use snapshots, run a dedicated mysql
service, point Shopware at it, and set db.service: db — the zip's files show
both, commented out.
Name of the compose service (the key under services:) Traefik talks to. Omit it → auto-detection (see below).
port
recommended
Container-internal port your service serves HTTP on (e.g. 3000, 8080, 80).
domain
optional
An extra Host() rule alongside <slug>.preview.<your-domain> — e.g. a fixed prod-preview domain.
db.service
optional
The compose service a database snapshot is restored into (mounted into its /docker-entrypoint-initdb.d/). Required only if you use DB snapshots.
setup
optional
Shell commands run aftercompose up, in order, inside the primary container (e.g. migrations, seeds). A non-zero step fails the boot.
views
optional
Navigable entry points shown as a toggle in the preview UI (each has a label + path).
hooks
optional
Repo-declared commands the preview-agent runs around each run: hooks.pre_agent (before) and hooks.post_agent (after). The in-repo equivalent of the Pre-/Post-run commands in the UI. Each entry is a plain string or a { run, service, timeout } mapping (timeout in seconds, default 300, max 1800).
hot_reload
optional
true skips the post-edit image rebuild for dev-mode stacks that bind-mount the repo source and hot-reload it themselves (Nuxt/Vite/Next dev). Leave unset for build-based previews (source baked in at build time) — the supervisor auto-detects the common bind-mount case.
.spedy/preview.yaml (with an a) is accepted too.
Important:port is the port inside the container, not a host mapping. Your service must listen on it inside the container (0.0.0.0:<port>, not 127.0.0.1). Host port mappings (ports:) in your Compose are stripped by Spedy — parallel previews would otherwise collide — so you don't need them for the preview.
These apply to the host running the Spedy supervisor (your self-hosted runner). The build-based model needs only items 1–2; the bind-mount model also needs item 3.
Docker socket. The supervisor drives the host daemon to boot preview stacks. Mount it into the supervisor service:
A preview-ingress Traefik on the shared network. Previews are attached to an external Docker network named spedy-preview with Traefik routing labels; a Traefik instance on that network turns the per-ticket host into a route. The supervisor creates the network if missing, but the ingress Traefik must join it:
preview-traefik: image: traefik:v3.7 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --providers.docker.network=spedy-preview - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 # must exist or per-preview https routers are dropped ports: - "127.0.0.1:9880:80" # the supervisor proxies relay traffic here volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: - spedy-previewnetworks: spedy-preview: name: spedy-preview external: true
Without it, preview traffic forwards to a dead 127.0.0.1:9880 and the browser gets a 404.
Shared clone workspace — only for the bind-mount model. Because bind sources resolve host-side, the supervisor's clone root must be a host bind mount at an identical path inside and outside the container. The default is /srv/spedy-previews:
volumes: - /srv/spedy-previews:/srv/spedy-previews # same path on host and in the container
The build-based model does not need this — skip it entirely if all your previews build their images.
Never let a preview boot your runner infrastructure. If the same Compose file that defines your supervisor / ingress Traefik is also what a preview boots, a bare compose up would start those inside the preview and hand it the host Docker socket — a container escape. Keep preview infra out of the file the preview uses: ship a separate docker-compose.preview.yml that contains only your app stack (this is the recommended model), or gate the infra services behind a Compose profiles: so compose up skips them.
On boot the supervisor renders a derived docker-compose.spedy.yml next to your Compose file (so relative build/context paths still resolve to the repo). In doing so it:
stripsports: (host mappings) and container_name on all services (collisions across parallel previews),
hardens every service (drops privileged, cap_add, host devices, security_opt, host namespace sharing, and unsafe bind mounts like /var/run/docker.sock or .. — the Compose is attacker-influenced),
attaches the primary service to the external spedy-preview network,
injects Traefik labels (routing to <slug>.preview.<your-domain>, loadbalancer.server.port = your port),
mounts the cloned workspace at /opt/spedy-workspace in the primary container (where the coding agent works).
The generated docker-compose.spedy.yml is ignored locally via .git/info/exclude — it never lands in your repo/PR.
After the coding agent edits the workspace, its changes appear in the preview as follows:
Build-based preview: the supervisor rebuilds the image (--build) after an agent run, so the new source is baked in and served. Reliable on any host.
Bind-mount preview with a dev server: if your stack runs in watch mode (next dev / node --watch / vite) from the mounted source, edits show up immediately without a rebuild.
.spedy/preview.yml can do more than name the service and port.
Database snapshots are uploaded and selected in the product UI, not committed to the repo — see Preview Environments → Upload a database snapshot. At boot the supervisor downloads the resolved snapshot next to your clone as .spedy/snapshot.sql.gz (git-ignored, never committed) and mounts it into the /docker-entrypoint-initdb.d/ of the service you name in db.service — the postgres/mysql/mariadb image imports it on first boot. So all the repo declares is which service is the database:
db: service: db # the compose service the snapshot restores into
setup: runs shell commands once, in order, inside the primary container right after compose up — migrations, seeds, an asset build. A non-zero step fails the boot.
hooks.pre_agent / hooks.post_agent run around every preview-agent run — the repo-committed equivalent of the Pre-/Post-run commands under Project → Agent Setup → Preview Agent. Each entry is a plain string or a { run, service, timeout } mapping:
.spedy/├── preview.yml # service/port (+ domain, db, setup, views, hooks, hot_reload)└── snapshot.sql.gz # written by the supervisor at boot from the selected # platform snapshot — git-ignored, never committed
ERR_PNPM_NO_PKG_MANIFEST: No package.json found in /app (empty /app)
Bind-mount model on a host without the shared clone workspace → either switch to a build-based docker-compose.preview.yml, or add - /srv/spedy-previews:/srv/spedy-previews to the supervisor (item 3 above).
ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite (crash loop)
The image's Node is too old for the pinned pnpm (pnpm ≥ 11 needs Node ≥ 22.13). Pin a compatible pnpm (corepack prepare [email protected]) or use a node:22 base image.
The supervisor / ingress Traefik appears inside a preview's container list
The preview booted a Compose file that contains your runner infra → ship a separate docker-compose.preview.yml with only the app stack, or gate infra behind a profiles:.
Start aborts: "could not identify a primary HTTP service"
No service with a port + no .spedy/preview.yml → declare service + port.
Preview loads but is 502 / empty
Your service isn't listening on the container port given in port, or it binds 127.0.0.1 instead of 0.0.0.0.
Preview host returns 404
No preview-ingress Traefik on the spedy-preview network on the runner host (item 2 above).
The wrong service is routed
Auto-detection picked the wrong one → set service explicitly, or add labels: { spedy.primary: "true" }.
docker-compose.spedy.yml shows up in git status
It should be ignored via .git/info/exclude; if not, restart the preview once (the supervisor re-adds the entry on boot).