Skip to content

feat(proxy): run multiple local shops in parallel behind a shared proxy - #1208

Draft
Tomasz Turkowski (tturkowski) wants to merge 250 commits into
nextfrom
feat/local-proxy-multiple-shops
Draft

feat(proxy): run multiple local shops in parallel behind a shared proxy#1208
Tomasz Turkowski (tturkowski) wants to merge 250 commits into
nextfrom
feat/local-proxy-multiple-shops

Conversation

@tturkowski

@tturkowski Tomasz Turkowski (tturkowski) commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What changed?

New command group shopware-cli project proxy — run any number of local shops in parallel under stable hostnames, instead of everyone fighting over 127.0.0.1:8000.

Command Purpose
proxy setup one-time machine setup: wildcard DNS + HTTPS trust (single sudo ceremony; --domain, --skip-trust)
proxy up / down register/deregister the current project — fully reversible
proxy list / status overview of registered shops with their URLs
proxy verify bottom-up health check of the whole chain, with actionable hints
proxy teardown deregister everything and stop the shared infrastructure
$ shopware-cli project proxy list

  shop1.shopware.local  running  ~/shops/shop1
    Shop      https://shop1.shopware.local
    Admin     https://shop1.shopware.local/admin
    Adminer   https://adminer.shop1.shopware.local
    Mailpit   https://mailer.shop1.shopware.local

  shop2.shopware.local  running  ~/shops/shop2
    ...

Under the hood: one shared Traefik container routes by hostname (shops publish no host ports at all), a tiny DNS server embedded in the binary answers *.shopware.local → 127.0.0.1, and an mkcert-compatible local CA provides trusted HTTPS. Proxy mode is a marker-guarded compose.override.yaml — the base compose.yaml stays untouched, so project dev and manual docker compose keep working. up points APP_URL, the sales-channel domain and the project config at the proxy; down restores every value exactly.

➡️ Architecture, design decisions and trade-offs: docs/proxy.md

Why?

The dev environment publishes fixed host ports, so a second shop can't start — anyone working on multiple projects juggles ports or stops shops. Routing by hostname removes the conflict by construction, and trusted HTTPS matters for testing payment providers locally.

How was this tested?

  • go test ./... green, golangci-lint run ./... — 0 issues
  • unit tests for the pure logic: DNS wire format (incl. zone-spoofing edge cases), compose override generation, YAML/env surgery with exact-restore semantics, registry/settings round-trips, all user-facing guidance texts
  • manually end-to-end on macOS: three shops in parallel over trusted HTTPS, repeated up/down/teardown cycles with byte-identical restore of .shopware-project.yml, .env.local and the sales-channel domain; verify ladder validated against a real corporate sudo-block scenario

Related issue or discussion

Closes #1094, related: #939

- Calculate max overlay lines based on terminal height instead of fixed 10
- Change overlay sizing from percentage (80%) to absolute dimensions
- Add overlayMaxLines() function for responsive log buffer
# Conflicts:
#	cmd/project/project_create.go
#	go.mod
Rework the Instance tab log handling so each source keeps its own scrollback
and switching between them no longer discards output. All sources now stream
in the background via a tagged fan-in channel, so returning to a source shows
its previous logs. Sidebar arrow-key navigation now follows the visual group
order (containers, processes, files) instead of raw discovery order, running
processes render a hollow dot distinct from the active source's solid dot, and
stopping a watcher removes its entry from the sidebar.
Show only the relevant Start or Stop entry per watcher in the command palette
based on its current state, instead of listing both. A watcher counts as active
when running or starting, so the palette drops from four watcher rows to two.
* feat(devtui): redesign overview tab with setup health report

Split the overview into a two-column layout: a readonly project report on
the left and a "User action" column with the watchers on the right, with a
stacked fallback for narrow terminals.

- Merge Admin Access and Services into one Access table with the Shop
  Admin login as the first row; credential-less services show "no auth"
- Add a Setup health report: PHP version and memory limit read from the
  project's PHP runtime via the executor, admin worker and Flow Builder
  log level resolved from config/packages for the project's APP_ENV
- Move background processing to the left column and render it with the
  same status dots as the health checks
- Make all URLs and health check names clickable OSC 8 hyperlinks in the
  shared underlined link style; check names open the relevant docs page
- Drop arrow-key focusing of services; only the watchers remain focusable

* fix(devtui): resolve golangci-lint findings in overview health rendering

* fix(devtui): address Codex review findings on overview layout

- Raise the two-column breakpoint to 110 so the Access table cannot wrap
  into the right column on 100-107 column terminals
- Refresh discovered services and setup-health checks after a config save
  restarts the containers, instead of showing stale runtime values
)

* feat(devtui): track usage telemetry for setup flows and dashboard

Add anonymous usage events to the project dev TUI so we can see where
the setup wizards lose users, whether Docker environments start
reliably, and which dashboard features are used:

- project.dev.install: install wizard outcome incl. abandoned step,
  failed deployment-helper step, and chosen language/currency
- project.dev.migration_wizard: replaces migration_wizard_completed,
  now also reporting cancelled (with step) and failed runs
- project.dev.docker_start: compose up duration and result, for both
  initial startup and config-change restarts
- project.dev.action: command-palette actions with task result/duration
- project.dev.watcher: per-watcher-run uptime and end reason
- project.dev.health: one-per-session setup-health snapshot (levels only)
- project.dev.session: session duration, tabs visited, action counts,
  and stop-containers vs keep-running exit choice

All state lives in a nil-safe telemetryState shared across Model copies;
outcome events latch so quit paths cannot double-report. No URLs,
credentials, or free-text input are ever sent. Events are documented in
docs/TELEMETRY.md, and a package TestMain sets DO_NOT_TRACK so tests
never emit real events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: resolve golangci-lint issues

Check the os.Setenv error in TestMain, make the healthLevel switch
exhaustive, and extract watcher message handling from Model.Update into
updateWatcherMsg to bring its cyclomatic complexity back under the limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(devtui): report in-flight config restarts on quit, extract tag constants

Address PR review feedback:

- A config-change container restart still in flight when the user leaves
  the dashboard is now reported as a cancelled docker_start event from
  shutdown() instead of being silently dropped (matching the
  initial-start quit path).
- Extract the result / watcher-end / exit tag string values into
  constants in telemetry.go so call sites can't drift apart. Tests keep
  asserting the literal wire values on purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tracking): centralize event name and tag key constants

Move all telemetry event names, tag keys, and shared result values into
internal/tracking (tracking.Event*, tracking.Tag*, tracking.Result*) so
every Track caller — cmd/root.go, project create/upgrade-check, and the
dev TUI — shares one vocabulary instead of scattered string literals.
The devtui package keeps only its domain-specific tag values (watcher
end reasons, session exit choices). Tests still assert the raw wire
strings on purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(devtui): align telemetry tags with ClickHouse materialized columns

The analytics events table materializes result and duration_ms columns
from tags, so events benefit from reusing those keys:

- watcher: uptime_ms -> duration_ms
- session: exit -> result (stop_containers / keep_running / quit)
- health: instead of one event with dynamic per-check keys (whose
  php_version key collided with the migration wizard's php_version
  value tag), send one event per check tagged check + result

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
project create verified Docker/PHP/Composer availability up front, but
project dev did not, so cloning an existing project and running dev
failed with raw executor errors instead of actionable guidance.

Extract the check into system.ValidateProjectDependencies and run it
when setting up the dev environment (dev, dev start/stop/status),
based on the resolved environment type. The rendered help text is now
parameterized so each command phrases the action and the Docker switch
hint appropriately.


Claude-Session: https://claude.ai/code/session_01PkMbTc3KbdJ9bPN8W52ix7

Co-authored-by: Claude <noreply@anthropic.com>
…inner (#1172)

* feat(project): support live dependency installation logs toggle in spinner

Allows users to toggle dependency installation logs during project creation using Ctrl+L. Extracted the spinner with logs viewport into a reusable component under internal/tui package.

* fix(tui): resolve makezero linter error in progress spinner
When a project ships a local phpstan.neon whose excludePaths fully
excludes one of the detected source directories, PHPStan prints a plain
text "No files found to analyse." message instead of JSON. This caused
project validate --only=phpstan to report a confusing unmarshal error.

Skip such directories instead, and include PHPStan's stdout in the
unmarshal error message when stderr is empty so remaining failures are
diagnosable.

Fixes #833


Claude-Session: https://claude.ai/code/session_01Sh7Kd4repLWbXhDXkn5ZXw

Co-authored-by: Claude <noreply@anthropic.com>
The formatter re-indented the children of every element, which changed
the rendered output of <pre> and <textarea> — their content is
whitespace-sensitive per HTML rules.

Emit the children of these tags byte-for-byte instead, including nested
elements and Twig control structures, which are rendered inline without
inserting or removing any whitespace.


Claude-Session: https://claude.ai/code/session_015NdDBo9srRM7QULuNyigtT

Co-authored-by: Claude <noreply@anthropic.com>
The go-composer metadata includes branch dev builds like "6.7.12.x-dev".
These parse cleanly and satisfy the version constraint, so they leaked
into the interactive patch-version picker. The previous dev filter only
skipped the "dev-" prefix, missing the ".x-dev" suffix form.

Skip versions whose prerelease is "dev" so only installable stable/RC
releases are offered. Passing a dev version explicitly still resolves.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@tturkowski
Tomasz Turkowski (tturkowski) force-pushed the feat/local-proxy-multiple-shops branch from 4f04d9f to 4b3b422 Compare July 17, 2026 12:32
Adds `shopware-cli project proxy` (setup/up/down/list/status/verify/
teardown): a shared Traefik container routes stable hostnames like
https://shop1.shopware.local to local projects, so shops publish no host
ports and any number can run at once.

- embedded wildcard DNS server (x/net/dns) on 127.0.0.1:53535, wired via
  /etc/resolver (macOS) or systemd-resolved (Linux) by a one-time
  `proxy setup` with a single sudo ceremony (--domain, --skip-trust)
- trusted HTTPS out of the box: mkcert-compatible local CA, per-project
  wildcard SANs, trust-store install via smallstep/truststore
- proxy mode is a marker-guarded compose.override.yaml (ports cleared with
  !reset, requires Compose >= 2.24); the base compose.yaml stays untouched,
  so `project dev` and manual docker compose keep working in both modes
- `up` points APP_URL, the sales channel domain and the url keys in
  .shopware-project.yml at the proxy; `down` restores everything exactly
- `verify` checks the whole chain bottom-up with actionable hints,
  including guidance when sudo is blocked or systemd-resolved is missing
- docs/proxy.md explains the architecture, decisions and trade-offs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@shyim

Soner (shyim) commented Jul 20, 2026

Copy link
Copy Markdown
Member

Whats definitively missing here is:

  • How does this work on WSL2, seperate network, DNS?
  • What about the watchers (thats the most hard part)
  • SSL Certificate injection into containers and basic reachability, how does container A reach Container B over SSL and that domain

@tturkowski

Copy link
Copy Markdown
Contributor Author

Dev watchers through the shared proxy

A quick summary of how the admin/storefront watchers can work behind the shared reverse proxy.

Admin watcher — works as-is, no code changes

The admin is Vite-only. Vite works out its own HMR connection from the page it's loaded on, so all that was needed:

  • route the admin-watch.<shop> hostname through Traefik to the Vite dev server, and
  • show that URL in the TUI.

You open https://admin-watch.shop1.shopware.local directly and HMR just works.

Storefront watcher (now) — webpack + a small runtime patch

The storefront's classic watcher (HMR + webpack, @deprecated, to be removed in 6.9) exposes two fixed ports (9998 + 9999). This is a blocker for our proxy: the browser's hot-reload websocket target (hostname + port) is baked into the vendor code (webpack-dev-server's client.webSocketURL, hardcoded to 0.0.0.0) and can't be set from any project file or env var. That's what stops it from routing through our single-port proxy.

Rather than patching vendor file, we inject a tiny preload script when launching the watcher (Node --require) that overrides webSocketURL at runtime, pointing it at storefront-watch.<shop> through the proxy. The vendor code runs untouched; we just correct one value on the way through.

Result: the storefront watcher runs fully through the proxy - multiple shops in parallel, clean port-free hostnames, no exposed ports, and no change to Shopware or the shop. You browse https://storefront-watch.shop1.shopware.local.

Tradeoff: it's a runtime patch — clever but hidden, and it leans on the internals of shopware/shopware code. If that internal behavior ever changes (I don't think it will, but it feels worth mentioning), hot-reload could quietly stop working with no obvious error. In my opinion it's acceptable for a bridge on a code path that's going away.

Storefront watcher (future) — Vite, the clean path

From 6.7.11 the storefront also ships a Vite dev server. To make that work behind a reverse proxy we need a small, fully backward-compatible contribution to shopware/shopware (Storefront bundle):

  • the dev import-map plugin should use Vite's server.origin instead of hardcoding http://localhost:<port>, and
  • the vite config should set origin / allowedHosts / host from an env var when it's set

With that, the storefront watcher - once enabled, works at the shop's own URL (https://shop1.shopware.local) - no separate hostname.

Plan proposal:

  • implement the webpack + runtime-patch path now (it covers every shop, since webpack is on everything until 6.9)
  • file the Vite contribution in parallel, and once it's done we can offer Vite as the watcher for newer shops - gradually migrating off the runtime patch, which we can track via telemetry. Webpack won't be removed until 6.9, so there's plenty of runway.

@shyim

Copy link
Copy Markdown
Member

btw because of excactly those REASONS I DONT WANT TO have those watchers directly inside Shopware. we're like now screwed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants