Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions docs/Installation/demo-environment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Run a local demo

This page gets a working Stackiq running on your own machine in two commands. You end with a software catalogue, browsable and publishable.

It is a **demo**, not a development environment. Nothing is mounted from a checkout, and that is deliberate — see [What this is not](#what-this-is-not).

## What you need

Docker, with Compose v2.23 or newer. Nothing else — no PHP, no Node, no Nextcloud.

```bash
docker --version
docker compose version
```

If `docker compose version` prints v2.22 or older, upgrade first. The compose file declares its scripts inline via `configs`, and older versions ignore the `content:` field **silently** — which produces an instance with no apps installed and nothing in the logs to explain why.

## Step 1 — get the compose file

```bash
curl -fsSLO https://raw.githubusercontent.com/ConductionNL/stackiq/development/stackiq-compose.yaml
```

A single self-contained file. There is nothing else to fetch and nothing to edit.

## Step 2 — start it

```bash
docker compose -f stackiq-compose.yaml up -d
```

The first run takes a few minutes: it pulls three images and downloads the application archives. Watch it work if you like:

```bash
docker compose -f stackiq-compose.yaml logs -f app-installer
```

You are looking for:

```
==> installing openregister <version>
==> installing thematiq <version>
==> installing integriq <version>
==> installing stackiq <version>
==> installing portaliq <version>
==> apps present: integriq openregister portaliq stackiq thematiq
```

Then Nextcloud installs itself and enables the apps **in dependency order**. OpenRegister goes first: it owns the registers and schemas the others declare against, and a leaf app enabled before it finds no register to attach to.

That is done when this returns `"installed":true`:

```bash
curl -s http://localhost:8606/status.php
```

## Step 3 — open the demo

| What | Where |
| --- | --- |
| **Public portal** | [http://localhost:8606/apps/portaliq/site?portal=demo](http://localhost:8606/apps/portaliq/site?portal=demo) |
| **Stackiq** | [http://localhost:8606/apps/stackiq/](http://localhost:8606/apps/stackiq/) |
| Admin interface | [http://localhost:8606](http://localhost:8606) — `admin` / `admin` |

### Why the portal needs `?portal=demo`

An instance can host several portals, and Portaliq resolves which one to serve in exactly two ways: an explicit `?portal=<slug>` parameter, or a request hostname matching a portal's **verified** domain.

There is deliberately no third mode and no "default portal" fallback, because a default is how a multi-tenant host ends up serving one tenant's content under another tenant's domain. The seeded demo portal ships with no domains — an install hook has no business claiming a hostname on your behalf — so the slug parameter is how you reach it.

To drop the parameter on a throwaway box, bind `localhost` to the portal yourself under **Portaliq → Portals → demo → Domains** and mark it verified.

## What gets installed, and why more than one app

| App | Why |
| --- | --- |
| `openregister` | **Required.** Every Connext app declares its registers and schemas against OpenRegister. |
| `thematiq` | Optional. Government theming. Absent, the UI renders unthemed rather than wrong. |
| `integriq` | Optional. The connector, for feeding in data from systems you do not control. |
| `stackiq` | The app this page is about. |
| `portaliq` | Renders the public portal. |

That OpenRegister dependency is **not declared** in `appinfo/info.xml` — no app in the fleet declares an `<app>` dependency — so nothing stops the App Store from installing stackiq without it. It would then load, find no register to attach to, and show you an empty app rather than an error. The compose file encodes the dependency the manifest does not.

## Verifying it actually worked

A page loading is not the same as a page working. Nextcloud serves its shell before the app decides whether it has anything to render, so an app URL returns HTTP 200 even when it resolves to nothing at all. A smoke test that checks for a 200 would call that a success.

Check content instead:

```bash
# Should name the portal, not answer {"error":"not_found"}
curl -s "http://localhost:8606/apps/portaliq/api/content/site?portal=demo"

# OpenRegister has registers — an empty list means the configuration
# was never imported, which is not the same as "nothing configured yet"
curl -s -u admin:admin "http://localhost:8606/apps/openregister/api/registers" | head -c 300
```

## Changing the defaults

The port and every version are overridable:

```bash
DEMO_PORT=9000 \
STACKIQ_VERSION=1.2.3 \
docker compose -f stackiq-compose.yaml up -d
```

Leaving a version empty resolves the newest release for that app, pre-releases included — which is what most Connext apps still ship, so that is the default.

## Tearing it down

```bash
# Stop, keep the data
docker compose -f stackiq-compose.yaml down

# Stop and delete everything, including the database
docker compose -f stackiq-compose.yaml down -v
```

## What this is not

**It is not a development environment, and it cannot be turned into one by adding a bind mount.**

Nextcloud installs and updates an app by deleting the app directory and extracting a fresh archive over it. Point that at a checkout and an app-store update will delete your working tree — measured on a development machine on 27 August 2026, where `\OC\Updater::upgradeAppStoreApp` fired on a container restart and removed every top-level file from a bind-mounted checkout, including its `.git` directory. Only the subdirectories it lacked permission to unlink survived.

So this compose keeps its apps in a named volume and installs them from release archives. That also happens to be the only thing that works: a release archive is a **complete** app carrying `vendor/` and the built `js/` bundle, while a `git clone` carries neither — and a Nextcloud app with no `vendor/` does not fail loudly. It warns once and keeps loading, so the app appears installed while every service that needs a dependency is quietly absent.

To work *on* these apps rather than *with* them, use the development environment instead.

## Troubleshooting

**`app-installer` exits non-zero.** It could not download an archive. Check the log for the URL it tried; the most common cause is a pinned version with no matching release.

**It stops with `openregister missing; aborting`.** Deliberate. Every other app declares registers against OpenRegister, so a stack without it would start and then fail in a dozen confusing ways instead of one clear one.

**The UI renders unthemed.** Thematiq is not installed or not enabled. Expected, and cosmetic — the theme resolver renders unthemed rather than wrong when it is absent.

**Everything returns 404 or a maintenance page after a restart.** Nextcloud is waiting for an upgrade. Run `docker compose -f stackiq-compose.yaml exec -u www-data nextcloud php occ upgrade`.
254 changes: 254 additions & 0 deletions stackiq-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# Stackiq — local demo environment
#
# docker compose -f stackiq-compose.yaml up -d
#
# Then open http://localhost:8606/apps/stackiq/
# Admin UI: http://localhost:8606 (admin / admin)
#
# Tear down, including all data:
# docker compose -f stackiq-compose.yaml down -v
#
# ---------------------------------------------------------------------------
# THIS IS A DEMO ENVIRONMENT, NOT A DEVELOPMENT ENVIRONMENT.
#
# Nothing here is bind-mounted from your working copy, and that is deliberate
# rather than merely simpler. Nextcloud installs and updates an app by DELETING
# its directory and extracting a fresh archive over it. Measured 2026-08-27 on
# a development machine: `\OC\Updater::upgradeAppStoreApp` fired on a container
# restart and removed every top-level file from a bind-mounted checkout —
# including its `.git` directory — leaving only the subdirectories it lacked
# permission to unlink. A demo rig has no business pointing at a checkout, so
# this one owns its apps in a named volume.
#
# If you want to work ON these apps rather than WITH them, use the development
# environment instead. This file cannot serve that purpose and should not try.
#
# ---------------------------------------------------------------------------
# WHY RELEASE TARBALLS RATHER THAN `git clone`
#
# A release tarball is a COMPLETE app: it carries `vendor/` and the built `js/`
# bundle. A git checkout carries neither, and a Nextcloud app whose `vendor/`
# is missing does not fail loudly — `include_once` warns and the app keeps
# loading, so the app appears installed while every service that needs a
# dependency is absent.
#
# ---------------------------------------------------------------------------
# WHAT IS INSTALLED, AND WHY MORE THAN ONE APP
#
# openregister REQUIRED. Every Connext app declares its registers and
# schemas against OpenRegister. Note that this dependency is
# NOT declared in appinfo/info.xml — no app in the fleet
# declares an <app> dependency — so nothing stops the App
# Store installing stackiq without it. It would then load
# and find no register to attach to.
# thematiq Optional. Government theming. Absent, the UI renders
# unthemed rather than wrong.
# integriq Optional. The connector, for feeding data in from systems
# you do not control.
# stackiq the app this file is for.
#
# Versions float to the newest release by default, pre-releases included,
# because most Connext apps do not yet publish a stable one. Pin any of them:
#
# STACKIQ_VERSION=1.2.3 docker compose -f stackiq-compose.yaml up -d
#
# The Nextcloud image is pinned to a MAJOR tag rather than `:latest`. A demo
# that is stopped and restarted weeks later would otherwise boot a drifted
# Nextcloud over its existing data volume, which lands the instance in
# maintenance mode with its apps disabled.

name: stackiq-demo

volumes:
db:
nextcloud:
apps:

services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: nextcloud
POSTGRES_DB: nextcloud
volumes:
- db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"]
interval: 5s
timeout: 3s
retries: 20

# Downloads each app's release tarball into the shared `apps` volume before
# Nextcloud starts. It runs to completion and exits; Nextcloud waits for that
# exit via `condition: service_completed_successfully`, so there is no window
# in which Nextcloud boots against a half-populated app directory.
#
# Idempotent: an app whose appinfo/info.xml is already present is skipped, so
# `up` on an existing demo does not re-download 100MB per app.
app-installer:
image: alpine:3.20
restart: "no"
environment:
OPENREGISTER_VERSION: ${OPENREGISTER_VERSION:-}
THEMATIQ_VERSION: ${THEMATIQ_VERSION:-}
INTEGRIQ_VERSION: ${INTEGRIQ_VERSION:-}
STACKIQ_VERSION: ${STACKIQ_VERSION:-}
PORTALIQ_VERSION: ${PORTALIQ_VERSION:-}
volumes:
- apps:/apps
configs:
- source: install-apps
target: /install-apps.sh
mode: "0755"
command: ["/bin/sh", "/install-apps.sh"]

nextcloud:
image: nextcloud:34-apache
restart: unless-stopped
ports:
- "${DEMO_PORT:-8606}:80"
depends_on:
db:
condition: service_healthy
app-installer:
condition: service_completed_successfully
environment:
POSTGRES_HOST: db
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: nextcloud
POSTGRES_DB: nextcloud
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: admin
# The port has to appear here as well as in `ports:`. Nextcloud rejects a
# request whose Host header names a domain it does not trust, and
# "localhost" and "localhost:8606" are different entries.
NEXTCLOUD_TRUSTED_DOMAINS: "localhost localhost:${DEMO_PORT:-8606} 127.0.0.1 127.0.0.1:${DEMO_PORT:-8606}"
# OVERWRITECLIURL IS LOAD-BEARING, NOT COSMETIC. Apps that federate
# advertise this address to peers. It is a local address here, and a
# local address is refused rather than broadcast — which is what keeps a
# demo on somebody's laptop out of the national directory.
OVERWRITECLIURL: "http://localhost:${DEMO_PORT:-8606}"
OVERWRITEPROTOCOL: http
volumes:
- nextcloud:/var/www/html
- apps:/var/www/html/custom_apps
configs:
- source: enable-apps
target: /docker-entrypoint-hooks.d/post-installation/10-enable-connext-apps.sh
mode: "0755"

configs:
# EVERY SHELL VARIABLE BELOW IS WRITTEN `$$name`, NOT `$name`.
#
# Compose interpolates `$name` inside `configs.content` before the file is
# written, so an un-escaped shell variable arrives as an EMPTY STRING and the
# script runs on silently. Measured while building this file: `$version` was
# blanked, which made the "no version pinned" branch look true for an app
# whose version WAS pinned, and the resulting error named no repository —
# `could not resolve a release for ` — because `$repo` had been blanked too.
#
# `$$` is the escape that survives interpolation and reaches /bin/sh as `$`.
# `${DEMO_PORT:-8606}` is deliberately NOT escaped: that one is Compose's
# to substitute.
install-apps:
content: |
#!/bin/sh
set -eu
apk add --no-cache curl tar jq >/dev/null

# RESOLVING "NEWEST" TAKES TWO CORRECTIONS, NOT ONE.
#
# 1. The GitHub "latest release" endpoint EXCLUDES prereleases and answers
# 404 for a repository that has only ever shipped them — which reads
# exactly like "no such app". Ask the releases LIST instead.
#
# 2. THE LIST IS NOT ORDERED BY CREATION DATE. Taking the first entry looks
# correct and is not. Measured 2026-08-27 on openregister:
#
# v1.1.6 created 10:17:45 <- returned first
# v1.1.6-unstable.20260827110807 created 11:09:37 <- actually newest
#
# Taking the first entry installs an OLDER build than intended, and the
# failure surfaces far from the cause — as a missing CLASS in a
# different app, not as a version complaint.
#
# Sorting by created_at explicitly is the fix; jq is here for that.
resolve_latest() {
curl -fsSL "https://api.github.com/repos/ConductionNL/$$1/releases?per_page=50" \
| jq -r '[.[] | select(.draft | not)] | sort_by(.created_at) | last | .tag_name // empty' \
| sed 's/^v//'
}

install_app() {
repo="$$1"; appid="$$2"; version="$$3"; asset="$$4"

if [ -f "/apps/$$appid/appinfo/info.xml" ]; then
echo "==> $$appid already present, skipping"
return 0
fi

if [ -z "$$version" ]; then
version="$$(resolve_latest "$$repo")"
[ -n "$$version" ] || { echo "!! could not resolve a release for $$repo"; return 1; }
echo "==> $$appid: no version pinned, resolved $$version"
fi

url="https://github.com/ConductionNL/$$repo/releases/download/v$$version/$$asset-$$version.tar.gz"
echo "==> installing $$appid $$version"

# Extract into a staging directory rather than straight into /apps.
# The archive's own top-level directory name is not guaranteed to equal
# the Nextcloud app id — Nextcloud resolves an app by its DIRECTORY
# name, so an archive that unpacks under the old name after a rename
# yields an app that is silently never loaded. Several Connext apps were
# renamed in August 2026, so this is a live concern, not a hypothetical.
rm -rf /tmp/stage && mkdir -p /tmp/stage
curl -fsSL "$$url" | tar -xz -C /tmp/stage

top="$$(ls /tmp/stage | head -n1)"
if [ "$$top" != "$$appid" ]; then
echo " archive unpacked as '$$top', installing it as '$$appid'"
fi
mv "/tmp/stage/$$top" "/apps/$$appid"
rm -rf /tmp/stage
}

# OpenRegister is not optional. If it could not be fetched, stop here
# rather than let Nextcloud boot into a dozen confusing downstream
# failures instead of one clear one.
install_app openregister openregister "$$OPENREGISTER_VERSION" openregister
install_app thematiq thematiq "$$THEMATIQ_VERSION" thematiq
install_app integriq integriq "$$INTEGRIQ_VERSION" integriq
install_app stackiq stackiq "$$STACKIQ_VERSION" stackiq
install_app portaliq portaliq "$$PORTALIQ_VERSION" portaliq

[ -f /apps/openregister/appinfo/info.xml ] || { echo "!! openregister missing; aborting"; exit 1; }

# 33 is www-data inside the Nextcloud image.
chown -R 33:33 /apps
echo "==> apps present: $$(ls /apps | tr '\n' ' ')"

enable-apps:
content: |
#!/bin/sh
set -eu
# Runs once, after Nextcloud has installed itself.
#
# ORDER IS NOT ARBITRARY. OpenRegister owns the registers and schemas the
# other apps declare against, and a leaf app enabled before it finds no
# register to attach to. Enabling them in dependency order is what makes
# a first boot produce a working instance instead of an empty one.
for app in openregister thematiq integriq stackiq portaliq; do
echo "==> enabling $$app"
php /var/www/html/occ app:enable "$$app" || echo "!! failed to enable $$app"
done

# Several apps ship a schema whose slug is not unique across the
# instance. OpenRegister resolves a duplicate slug by tie-break and warns,
# which means a leaf app can silently read another app's schema. This is a
# no-op on a clean install and a repair on one that has drifted.
php /var/www/html/occ openregister:schemas:dedup || true

echo "==> Stackiq demo: http://localhost:${DEMO_PORT:-8606}/apps/stackiq/"
Loading