diff --git a/apps/conduit-market/manifest.yml b/apps/conduit-market/manifest.yml
new file mode 100644
index 00000000..01275f67
--- /dev/null
+++ b/apps/conduit-market/manifest.yml
@@ -0,0 +1,102 @@
+app:
+ id: conduit-market
+ name: Conduit Market
+ version: 1.0.0
+ # Built by this project from a pinned upstream commit (Conduit ships no
+ # Dockerfile of its own) — there is no upstream release feed/tag to watch,
+ # so re-pin CONDUIT_COMMIT in the Dockerfile deliberately, by hand.
+ upstream:
+ kind: github
+ repo: Conduit-BTC/conduit-mono
+ description: |
+ Decentralized Nostr + Lightning marketplace (apps/market from
+ Conduit-BTC/conduit-mono). Pure client-side SPA — listings are NIP-99
+ events, orders are encrypted DMs, payments settle over Lightning via
+ NWC — confirmed by reading packages/core/src/config.ts and
+ apps/market/package.json directly; there is no custom backend to run.
+
+ Public/private is a genuine runtime toggle, not two separate builds:
+ Vite bakes VITE_* relay env vars into the bundle at compile time (see
+ the comment on getViteEnv() in config.ts), so a single image built once
+ could never be reconfigured afterward via ordinary env vars. This
+ package patches config.ts (docker/conduit-market/apply-runtime-override.py)
+ to merge in a window.__CONDUIT_RUNTIME_ENV__ override object, written by
+ docker-entrypoint.sh from CONDUIT_MODE/CONDUIT_PRIVATE_RELAY_URLS at
+ container *start*. Flipping CONDUIT_MODE and restarting the container is
+ enough — no rebuild required. CONDUIT_MODE=private with no relay URL set
+ refuses to start rather than silently falling back to the public network.
+ category: money
+
+ container:
+ build:
+ context: /opt/archipelago/docker/conduit-market
+ dockerfile: Dockerfile
+ tag: localhost/conduit-market:local
+ network: archy-net
+
+ dependencies: []
+
+ resources:
+ memory_limit: 64Mi
+
+ security:
+ capabilities: []
+ # false, not true: docker-entrypoint.sh regenerates runtime-env.js under
+ # /usr/share/nginx/html (part of the image root fs, not a volume) on
+ # every container start — that's the actual public/private switch, so
+ # the root fs must stay writable for the mode flip to take effect.
+ readonly_root: false
+ no_new_privileges: true
+ network_policy: bridge
+
+ ports:
+ - host: 8091
+ container: 8080
+ protocol: tcp
+ bind: 127.0.0.1
+ auth: none
+ auth_rationale: >-
+ Public storefront meant for anonymous shoppers, not an admin
+ surface — Conduit has no login of its own (identity is a Nostr
+ keypair held client-side) and a cookie/session gate in front of it
+ would just block real customers from browsing or checking out.
+
+ volumes: []
+
+ # CONDUIT_MODE (public|private) and CONDUIT_PRIVATE_RELAY_URLS are the
+ # actual public/private switch, read by docker-entrypoint.sh at container
+ # start — see the description above. Defaults here are the safe/inert
+ # choice (public, using Conduit's own canonical relay network); an
+ # operator who wants an isolated single-relay storefront sets both.
+ environment:
+ - CONDUIT_MODE=public
+ - CONDUIT_PRIVATE_RELAY_URLS=
+ - CONDUIT_LIGHTNING_NETWORK=mainnet
+
+ health_check:
+ type: http
+ endpoint: http://127.0.0.1:8091
+ path: /health
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+ interfaces:
+ main:
+ name: Storefront
+ description: Conduit Market storefront
+ type: ui
+ port: 8091
+ protocol: http
+ path: /
+
+ metadata:
+ category: money
+ tier: optional
+ author: Conduit-BTC
+ repo: https://github.com/Conduit-BTC/conduit-mono
+ features:
+ - Nostr-native marketplace listings (NIP-99)
+ - Encrypted order flow over Nostr DMs
+ - Lightning checkout via Nostr Wallet Connect
+ - Runtime-configurable public or private relay mode, no rebuild
diff --git a/docker/conduit-market/Dockerfile b/docker/conduit-market/Dockerfile
new file mode 100644
index 00000000..30e405b2
--- /dev/null
+++ b/docker/conduit-market/Dockerfile
@@ -0,0 +1,62 @@
+# syntax=docker/dockerfile:1.6
+#
+# Packages Conduit Market (github.com/Conduit-BTC/conduit-mono, apps/market)
+# as a static Archipelago app. Conduit itself is a pure client-side SPA --
+# products are Nostr events (NIP-99), orders are encrypted DMs, payments go
+# over Lightning via NWC -- no custom backend, confirmed by reading
+# packages/core/src/config.ts and apps/market/package.json directly rather
+# than assumed. Upstream ships no Dockerfile; this one is new.
+#
+# Pinned to a specific upstream commit (not a moving branch) for
+# reproducibility, matching the archy convention already used for
+# apps/cuprate. Re-pin deliberately, not on every rebuild.
+#
+# oven/bun:1.1-slim is pinned to an EOL Debian bullseye base whose
+# -security pool no longer serves the package versions it references
+# (404s on ca-certificates/curl et al). oven/bun:1-slim currently resolves
+# to bun 1.4.x on Debian trixie (current), so use that instead -- still a
+# floating minor tag, but the alternative (hand-pinning a bullseye/bookworm
+# digest) just reintroduces the same staleness problem later.
+FROM oven/bun:1-slim AS build
+
+ARG CONDUIT_COMMIT=c6606382dda8953763646f4498b2c4a4103da0a3
+
+WORKDIR /build
+RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl python3 \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN curl -fsSL "https://github.com/Conduit-BTC/conduit-mono/archive/${CONDUIT_COMMIT}.tar.gz" \
+ -o /tmp/conduit.tar.gz \
+ && tar xzf /tmp/conduit.tar.gz --strip-components=1 \
+ && rm /tmp/conduit.tar.gz
+
+# Full workspace install: apps/market depends on @conduit/core and
+# @conduit/ui via workspace:*, so bun needs the whole monorepo present to
+# link them, even though only the market app actually gets built below.
+RUN bun install --frozen-lockfile
+
+COPY apply-runtime-override.py /build/apply-runtime-override.py
+RUN python3 apply-runtime-override.py
+
+# Loads window.__CONDUIT_RUNTIME_ENV__ (written by docker-entrypoint.sh at
+# container start) before the app bundle runs. A plain (non-module) script
+# tag with an absolute path: Vite copies these through to dist/index.html
+# verbatim without trying to resolve them as part of the module graph, and
+# does not require the file to exist in the source tree at build time.
+RUN sed -i 's##\n #' \
+ apps/market/index.html
+
+# Leave every VITE_* relay/network var unset here on purpose -- the runtime
+# override (see above) is the actual public/private switch; baking any of
+# these in at build time would defeat the point of a live-configurable
+# single image.
+RUN bun run --filter '@conduit/market' build
+
+FROM nginx:1.27-alpine
+
+COPY --from=build /build/apps/market/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY docker-entrypoint.sh /docker-entrypoint.d/50-conduit-runtime-env.sh
+RUN chmod +x /docker-entrypoint.d/50-conduit-runtime-env.sh
+
+EXPOSE 8080
diff --git a/docker/conduit-market/apply-runtime-override.py b/docker/conduit-market/apply-runtime-override.py
new file mode 100755
index 00000000..dc4c7258
--- /dev/null
+++ b/docker/conduit-market/apply-runtime-override.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""Patches packages/core/src/config.ts so it can be reconfigured
+public/private at container-start time instead of only at build time.
+
+Vite bakes VITE_* env vars into the bundle as literal strings when it
+compiles (import.meta.env.VITE_FOO is a static replacement, not a runtime
+lookup) -- confirmed directly in config.ts's own comment on getViteEnv().
+That means a container image built once can never be flipped between an
+isolated private relay and the public Conduit network via ordinary env
+vars after the fact; the values are frozen into the compiled JS.
+
+This packaging needs exactly that live toggle, so docker-entrypoint.sh
+writes a small /runtime-env.js at *container start* (from real env vars)
+setting window.__CONDUIT_RUNTIME_ENV__ before the app bundle runs. This
+script inserts one small override function right before the single call
+site that consumes getViteEnv()'s result (`const env = getViteEnv()`,
+confirmed to be the only call site in the file and to run once at module
+load), so only that one merge point needs to change. Only non-empty
+string fields override the Vite-baked default; a build with no runtime
+override behaves identically to stock upstream Conduit.
+
+Uses a literal string anchor rather than a line-numbered diff/patch --
+more robust against upstream reformatting a comment or reflowing
+unrelated lines than a traditional unified diff would be.
+"""
+import sys
+
+CONFIG_PATH = "packages/core/src/config.ts"
+ANCHOR = "const env = getViteEnv()"
+
+OVERRIDE_BLOCK = '''// --- Archipelago runtime relay override -------------------------------------
+// See docker/conduit-market/apply-runtime-override.py in the archy repo for
+// why this exists: Vite bakes VITE_* env vars into the bundle at build
+// time, so a container image built once can never be reconfigured
+// public/private afterward via ordinary env vars. docker-entrypoint.sh
+// writes window.__CONDUIT_RUNTIME_ENV__ from real environment variables at
+// container start, before this bundle runs.
+function getRuntimeOverrides(): Partial> {
+ if (typeof window === "undefined") return {}
+ const raw = (window as unknown as Record)
+ .__CONDUIT_RUNTIME_ENV__
+ if (!raw || typeof raw !== "object") return {}
+ const out: Record = {}
+ for (const [key, value] of Object.entries(raw as Record)) {
+ if (typeof value === "string" && value.length > 0) out[key] = value
+ }
+ return out as Partial>
+}
+
+const env = { ...getViteEnv(), ...getRuntimeOverrides() }'''
+
+with open(CONFIG_PATH) as f:
+ content = f.read()
+
+count = content.count(ANCHOR)
+if count != 1:
+ print(
+ f"FATAL: expected exactly 1 occurrence of anchor line in {CONFIG_PATH}, "
+ f"found {count}. Upstream config.ts has likely changed in a way that "
+ "needs this script re-checked by hand before it can safely patch it.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+content = content.replace(ANCHOR, OVERRIDE_BLOCK, 1)
+
+with open(CONFIG_PATH, "w") as f:
+ f.write(content)
+
+print(f"Patched {CONFIG_PATH}: runtime relay override installed.")
diff --git a/docker/conduit-market/docker-entrypoint.sh b/docker/conduit-market/docker-entrypoint.sh
new file mode 100644
index 00000000..09a38756
--- /dev/null
+++ b/docker/conduit-market/docker-entrypoint.sh
@@ -0,0 +1,43 @@
+#!/bin/sh
+# Runs via nginx's own /docker-entrypoint.d/ hook mechanism (official nginx
+# image sources every executable script there before starting nginx) --
+# no custom ENTRYPOINT needed. Writes /runtime-env.js, loaded by index.html
+# before the app bundle (see Dockerfile), so this container's own env vars
+# can flip Conduit between an isolated private relay and the public Conduit
+# network without rebuilding the image -- see
+# packages/core/src/config.ts's getRuntimeOverrides() (installed by
+# apply-runtime-override.py) for the other half of this.
+set -eu
+
+CONDUIT_MODE="${CONDUIT_MODE:-public}"
+OUT=/usr/share/nginx/html/runtime-env.js
+
+if [ "$CONDUIT_MODE" = "private" ]; then
+ if [ -z "${CONDUIT_PRIVATE_RELAY_URLS:-}" ]; then
+ echo "conduit-market: CONDUIT_MODE=private requires CONDUIT_PRIVATE_RELAY_URLS (comma-separated wss:// URLs) -- refusing to start with no relay configured" >&2
+ exit 1
+ fi
+ cat > "$OUT" < "$OUT" <
+ # private) followed by a container restart would silently keep serving
+ # the old relay config to anyone with a warm cache.
+ location = /runtime-env.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ expires 0;
+ }
+
+ # Client-side routing (@tanstack/react-router) -- unknown paths fall
+ # through to index.html rather than 404ing.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location = /health {
+ access_log off;
+ default_type text/plain;
+ return 200 "ok\n";
+ }
+}