SpedySpedy Docs

Preview Config (BYOC)

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:

  1. a Compose file that starts your stack — ideally a dedicated, build-based docker-compose.preview.yml (see below), and
  2. 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.

Minimal setup

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 to
port: 3000       # container port that serves HTTP

When you click "Start preview" on a ticket, the runner:

  1. clones the repo (on the ticket's feature branch),
  2. picks the Compose file (prefers docker-compose.preview.yml — see Which Compose file),
  3. reads .spedy/preview.yml,
  4. builds + boots the stack and routes service/port under <orgSlug>--<ticket>.preview.<your-domain>.

Which Compose file is used

The supervisor looks for a Compose file in this order and uses the first it finds:

docker-compose.preview.yml
docker-compose.preview.yaml
compose.preview.yml
compose.preview.yaml
docker-compose.yml
docker-compose.yaml
compose.yml
compose.yaml

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

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.yml
services:
  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/data

volumes:
  db_data:
# .spedy/preview.yml
service: app
port: 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:
    build:
      context: ./web
      additional_contexts:
        shared: ./shared
      dockerfile_inline: |
        FROM node:22-alpine
        WORKDIR /app
        COPY . /app
        COPY --from=shared . /shared
  • 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.

Alternative — bind-mount the source (requires host setup)

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.

Framework examples

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.

Nuxt

Download nuxt-preview.zip — a single Nuxt service that bakes the source in and runs the dev server on 0.0.0.0:3000.

# docker-compose.preview.yml
services:
  app:
    build:
      context: .
      dockerfile_inline: |
        FROM node:22-alpine
        WORKDIR /app
        RUN corepack enable
        COPY . /app
    command: sh -c "corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0 --port 3000"
    environment:
      - NODE_ENV=development
      - HOST=0.0.0.0
      - PORT=3000
# .spedy/preview.yml
service: app
port: 3000
views:
  - label: Storefront
    path: /

Shopware 6

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.yml
services:
  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
# .spedy/preview.yml
service: shopware
port: 80
views:
  - label: Storefront
    path: /
  - label: Admin
    path: /admin
hooks:
  post_agent:
    - run: bin/console theme:compile
      service: shopware
    - run: bin/console cache:clear
      service: shopware

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.

Fields in .spedy/preview.yml

FieldRequiredMeaning
servicerecommendedName of the compose service (the key under services:) Traefik talks to. Omit it → auto-detection (see below).
portrecommendedContainer-internal port your service serves HTTP on (e.g. 3000, 8080, 80).
domainoptionalAn extra Host() rule alongside <slug>.preview.<your-domain> — e.g. a fixed prod-preview domain.
db.serviceoptionalThe compose service a database snapshot is restored into (mounted into its /docker-entrypoint-initdb.d/). Required only if you use DB snapshots.
setupoptionalShell commands run after compose up, in order, inside the primary container (e.g. migrations, seeds). A non-zero step fails the boot.
viewsoptionalNavigable entry points shown as a toggle in the preview UI (each has a label + path).
hooksoptionalRepo-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_reloadoptionaltrue 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.

Runner host requirements

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.

  1. Docker socket. The supervisor drives the host daemon to boot preview stacks. Mount it into the supervisor service:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
  2. 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-preview
    networks:
      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.
  3. 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.

What Spedy does with your Compose

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:

  • strips ports: (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.

Live changes (agent edits → preview)

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.

Without .spedy/preview.yml: auto-detection

If the file is missing, the supervisor tries to find the primary service itself:

  1. A label wins: the service with labels: { spedy.primary: "true" }.
  2. Otherwise: the first service (alphabetically) with a ports: or expose: entry.

Port resolution (in order): port from preview.yml → the service's first ports:/expose: port → fallback 80.

If nothing matches, the start aborts with:

could not identify a primary HTTP service — declare it in .spedy/preview.yml (service + port), or add a ports:/expose: / spedy.primary label

Recommendation: declare service + port explicitly. It's unambiguous and survives renames / extra services.

DB snapshots, setup & agent hooks

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

setup:
  - php bin/console database:migrate --all
  - php bin/console theme:compile

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:

hooks:
  pre_agent:
    - composer install
  post_agent:
    - run: bin/console theme:compile
      service: shopware
      timeout: 600
    - bin/console cache:clear

The full .spedy/ layout:

.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

Troubleshooting

SymptomCause / fix
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 listThe 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 / emptyYour 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 404No preview-ingress Traefik on the spedy-preview network on the runner host (item 2 above).
The wrong service is routedAuto-detection picked the wrong one → set service explicitly, or add labels: { spedy.primary: "true" }.
docker-compose.spedy.yml shows up in git statusIt should be ignored via .git/info/exclude; if not, restart the preview once (the supervisor re-adds the entry on boot).