feat: add Workshop staging deployment

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:51:25 +02:00
1 parent c41a3d427e
commit 3ebcedec26
11 files changed
+207 -4

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
WORKSHOP_IMAGE=ghcr.io/homarr-labs/workshop:v2
WORKSHOP_EXPOSE_PORT=8090
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
WORKSHOP_API_URL=https://v2.preview.homarr.dev
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
# Optional GitHub OAuth application for the preview hostname.
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
+43
View File
@@ -162,6 +162,45 @@ WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
PB_ALLOWED_ORIGINS=*
```
### Deploy a v2 staging or demo instance
The staging Compose file runs the published `ghcr.io/homarr-labs/workshop:v2` image, persists `/pb_data`, binds only to
the host loopback interface, and expects TLS to terminate at a reverse proxy. It does not need a repository checkout to
run; copy `docker-compose.staging.yml` and `.env.staging.example` to the deployment host, then:
```sh
cp .env.staging.example .env.staging
docker compose --env-file .env.staging -f docker-compose.staging.yml pull
docker compose --env-file .env.staging -f docker-compose.staging.yml up --detach
docker compose --env-file .env.staging -f docker-compose.staging.yml ps
curl --fail http://127.0.0.1:8090/api/health
```
The checked-in example is ready for `https://v2.preview.homarr.dev`:
```dotenv
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
WORKSHOP_API_URL=https://v2.preview.homarr.dev
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
```
Proxy the entire `v2.preview.homarr.dev` hostname to `http://127.0.0.1:8090`, including `/api`, `/_/`, and
`/workshop-runtime-config.js`; do not proxy only `/workshop`. The combined image owns both the Docusaurus pages and
PocketBase routes. If the hostname already serves the Homarr application, use a dedicated Workshop hostname instead
and set all four public URL variables to that hostname (with `/workshop` only on `WORKSHOP_WEB_URL`).
The `v2` image tag is intentionally convenient and mutable for previews. Run `pull` followed by `up --detach` to test a
new v2 image while retaining the named PocketBase volume. Use the immutable `sha-<commit>` image tag when promoting a
tested build. The public central Workshop needs `PB_ALLOWED_ORIGINS=*` because Homarr instances on arbitrary origins
call the API and open the OAuth flow; a private demo can instead use a comma-separated allowlist of its browser origins.
After startup, open `https://v2.preview.homarr.dev/_/` to create the first PocketBase superuser. If GitHub OAuth is
enabled, configure its callback as `https://v2.preview.homarr.dev/api/oauth2-redirect`, set both GitHub credentials, and
restart the service. Verify `/workshop`, `/api/health`, listing requests, sign-in, and one installation from the Homarr
preview before sharing the demo.
Build and smoke-test the combined image locally:
```sh
@@ -172,6 +211,10 @@ curl --fail http://127.0.0.1:3003/workshop
docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs
```
`pnpm test:workshop-image` builds the production target, starts it with the preview URL contract, and proves that the
browser runtime configuration, Workshop page, health endpoint, migrations, public listing endpoint, and cross-origin
browser access all work.
Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional,
irreversible local reset:
+25
View File
@@ -0,0 +1,25 @@
name: homarr-workshop-staging
services:
workshop:
image: ${WORKSHOP_IMAGE:-ghcr.io/homarr-labs/workshop:v2}
restart: unless-stopped
environment:
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:?Set HOMARR_WEBSITE_URL in the Workshop environment file}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: 8090
WORKSHOP_API_URL: ${WORKSHOP_API_URL:?Set WORKSHOP_API_URL in the Workshop environment file}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:?Set WORKSHOP_PUBLIC_ORIGIN in the Workshop environment file}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
ports:
- "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090"
volumes:
- pb_data:/pb_data
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
volumes:
pb_data:
+2
View File
@@ -8,8 +8,10 @@ services:
target: development
restart: unless-stopped
environment:
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${PB_EXPOSE_PORT:-8090}
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:8090}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
set -eu
WORKSHOP_IMAGE_TEST_PORT=${WORKSHOP_IMAGE_TEST_PORT:-18091}
WORKSHOP_IMAGE_TEST_NAME="homarr-workshop-image-test-$$"
WORKSHOP_IMAGE_TEST_TAG="homarr-workshop:test-$$"
PREVIEW_ORIGIN=https://v2.preview.homarr.dev
cleanup() {
docker rm --force "$WORKSHOP_IMAGE_TEST_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker build --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" .
docker run --detach --name "$WORKSHOP_IMAGE_TEST_NAME" \
--publish "127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT:8090" \
--env HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \
--env WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
--env WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
--env WORKSHOP_PUBLIC_ORIGIN="$PREVIEW_ORIGIN" \
--env PB_ALLOWED_ORIGINS='*' \
"$WORKSHOP_IMAGE_TEST_TAG" >/dev/null
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
docker logs "$WORKSHOP_IMAGE_TEST_NAME"
exit 1
fi
sleep 1
done
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
EXPECTED_HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \
EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
node apps/workshop/tests/runtime-config.integration.mjs
curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null
curl --fail --silent \
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null
curl --fail --silent --dump-header - --output /dev/null \
--header 'Origin: https://homarr.example' \
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" \
| tr -d '\r' \
| grep -qi '^Access-Control-Allow-Origin: \*$'
echo "Workshop production image passed"
+10 -1
View File
@@ -14,7 +14,10 @@ cleanup() {
}
trap cleanup EXIT
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
HOMARR_WEBSITE_URL=https://preview.example.invalid \
WORKSHOP_API_URL=https://api.preview.example.invalid \
WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml up --build -d
for attempt in $(seq 1 60); do
@@ -29,6 +32,12 @@ for attempt in $(seq 1 60); do
sleep 1
done
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" \
EXPECTED_HOMARR_WEBSITE_URL=https://preview.example.invalid \
EXPECTED_WORKSHOP_API_URL=https://api.preview.example.invalid \
EXPECTED_WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
node apps/workshop/tests/runtime-config.integration.mjs
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \
pocketbase superuser create \
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
@@ -0,0 +1,24 @@
import vm from "node:vm";
const baseUrl = process.env.WORKSHOP_TEST_URL;
if (!baseUrl) throw new Error("WORKSHOP_TEST_URL is required");
const response = await fetch(`${baseUrl}/workshop-runtime-config.js`);
if (!response.ok) throw new Error(`Runtime configuration returned ${response.status}`);
const context = { TextDecoder, Uint8Array, atob };
context.globalThis = context;
vm.runInNewContext(await response.text(), context);
const actual = JSON.parse(JSON.stringify(context.homarrRuntimeConfig));
const expected = {
homarrWebsiteUrl: process.env.EXPECTED_HOMARR_WEBSITE_URL,
workshopApiUrl: process.env.EXPECTED_WORKSHOP_API_URL,
workshopWebUrl: process.env.EXPECTED_WORKSHOP_WEB_URL,
};
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Unexpected Workshop runtime configuration: ${JSON.stringify(actual)}`);
}
console.log("Workshop runtime configuration passed");