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

+2 -1
View File
@@ -23,8 +23,9 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @homarr/workshop typecheck
- run: pnpm --filter @homarr/docs typecheck
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
- run: pnpm test:workshop
- run: docker build --target production -f apps/workshop/Dockerfile .
- run: pnpm test:workshop-image
publish:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main')
@@ -112,8 +112,8 @@ The advanced deployments environment variables should only be used if you know w
| Environment Variable | Description |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. |
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. |
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. |
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
@@ -112,6 +112,42 @@ The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP
combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts;
the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it.
### Staging and demo deployment
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
```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=*
```
Start or update it from the repository root:
```sh
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml pull
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml up --detach
curl --fail http://127.0.0.1:8090/api/health
```
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
`/workshop` only on `WORKSHOP_WEB_URL`.
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
GitHub OAuth requires both credentials and the callback
`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health
endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview.
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
+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");
+1
View File
@@ -49,6 +49,7 @@
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop": "sh apps/workshop/test.sh",
"test:workshop-image": "sh apps/workshop/test-image.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"