SIAG ES Digital Exhibit 2026

A React + Vite Project for building kiosk-style digital exhibits: a PWA-capable single-page app with schema-driven configuration (via .env and URL params), an idle-triggered screensaver, offline-cacheable media, and analytics tracking. ()

Tech Stack

Getting Started

CODE
npm install
cp .env-sample .env   # then adjust values, see "Configuration" below
npm run dev

The app uses HashRouter, so URLs look like http://localhost:5173/#/. This matters for URL search params too - see the config section below.

If you switch to BrowserRouter (src/BrowserRouter.jsx, see App.jsx), VITE_BASE_URL is used both as Vite's base and as BrowserRouter's basename - it must be a path (e.g. / or /some-subpath/), not a full URL with protocol/host.

Available Scripts

ScriptDescription
npm run devStart the Vite dev server
npm run buildProduction build
npm run previewPreview a production build locally
npm run lintRun ESLint + Stylelint
npm run lint:fixSame, with autofix
npm run testRun Playwright tests
npm run test:uiRun Playwright tests in UI mode
npm run docsGenerate the JSDoc site into docs/ (see below)
npm run docs:serveServe the generated docs/ site on port 8080
npm run sbomGenerate a CycloneDX SBOM (sbom.xml)
npm run validate:contentValidate public/data/*.json (schema + references + assets, see "Content")

Configuration

Config values come from two sources - .env variables and URL search params - and are combined into a single, typed object via one hook: useConfig() (src/services/config/config.jsx).

Adding a new config value does not require writing new hook logic. Add one entry to CONFIG_SCHEMA in src/services/config/config-schema.js:

CODE
{
  key: "screensaverTimeout",   // property name on the returned config object
  source: "param",             // "env" (import.meta.env) or "param" (URL search param)
  name: "screensaverTimeout",  // the actual env var name (e.g. "VITE_...") or param name
  type: "number",              // "boolean" | "number" | "string"
  default: 180,
  min: 0,                      // optional, "number" type only
  description: "Seconds of inactivity before the screensaver starts.",
}

Values are validated/coerced generically by parseConfigValue() (src/utils/validationFunctions.js) - invalid or missing values fall back to default and log a warning.

The current schema (CONFIG_SCHEMA in config-schema.js) includes screensaverTimeout, screensaverFadeDuration, defaultMode (selects which screensaver video plays, see "Screensaver" below), contentFile, the globe* entries tuning InteractiveGlobe, and the stage*/stageDevice entries picking Stage's reference resolution (see "Scaling (Stage)" below).

Because the app uses HashRouter, URL params must come after the #:

CODE
http://localhost:5173/#/?screensaverTimeout=5&screensaverFadeDuration=300

A param placed before the # is invisible to useConfig() and silently falls back to the default. Navigation preserves the params: slide navigation, the app selection and the menu's "Apps" button all carry the current query string along, so a kiosk keeps its configuration for its entire uptime.

Presentation Modes & Navigation

The app hosts 3 presentation modes/sales apps, defined once in src/services/presentationModes.js (PRESENTATION_MODES, { key, label, path, orientation, contentFile|contentFiles }):

  • value-partnership → /value-partnership (landscape; bespoke interactive sub-app with its own nested routes - globe, excellence, partnerships - not a SlidePage deck like the two below)
  • value-advisory-services → /value-advisory-services (landscape)
  • rad-enablement → /rad-enablement (portrait-only)

orientation ("landscape" | "portrait") is each app's fixed, content-authored physical orientation - confirmed with the content team, not something a device negotiates at runtime. resolveModeForPath() (same file) maps a route to its mode - the one place this lookup happens, reused by Stage (see "Scaling (Stage)" below) and GeneralPageTracking so they can't disagree about which mode a path belongs to. / (app selection) matches no mode and defaults to landscape.

/ always renders the app selection screen (src/pages/AppSelection/AppSelection.jsx): one tile per mode, and the target of the idle session reset (see "Screensaver"). The 3 app routes are also always reachable directly by URL. Routing lives in src/Routes.jsx.

The two content-driven apps share one page skeleton, SlidePage (src/components/SlidePage/SlidePage.jsx): it resolves the :viewId route param against the loaded content, renders the view's elements via ComponentRenderer, and mounts SlideNavigation. Each page (ValueAdvisoryServices.jsx, RadEnablement.jsx) only supplies its constants, CSS prefix and an optional header. SlidePage also handles the failure states: a visible message while content can't be fetched, and a redirect to the start view for unknown view ids.

SlideNavigation (src/components/SlideNavigation/SlideNavigation.jsx) is the fixed bottom bar with three modes: the default prev/next arrows plus menu button; an expanded menu bar (Home / Screensaver / Apps / Slides / close); and the slides overlay (src/components/SlidesOverlay/SlidesOverlay.jsx) - a full-screen grid of slide thumbnails grouped by story, with a story toggle switch, for jumping directly to any slide. Thumbnails come from the optional thumbnail field of a view in the content JSON (/assets/thumbs/...); views without one get a text placeholder tile.

Page/app switches use the native View Transitions API directly via navigateWithTransition (src/utils/viewTransition.js, document.startViewTransition + flushSync) for a light cross-fade (tuned in src/styles/index.scss). react-router's own viewTransition navigate option is not used here - it only works with the Data Router API (createHashRouter/RouterProvider), and this app uses the plain declarative <HashRouter>/<Routes> instead.

Screensaver

ScreensaverProvider (src/services/screensaver.jsx) combines idle detection (useIdle, src/hooks/idle.jsx) with the screensaverTimeout/screensaverFadeDuration config values and exposes isIdle plus a manual trigger (showNow, wired to the menu's "Screensaver" button) via useScreensaver().

Waking from idle also resets the kiosk session: while the screensaver is still fading out, the app navigates back to the app selection (/, config params preserved), so the next visitor starts fresh instead of inheriting the previous visitor's route, open menus and selections. The reset deliberately happens on wake-up rather than at idle start - the screensaver video is defined by the current route's content file, and navigating away at idle start would unload it mid-show.

The actual overlay is src/components/Screensaver/Screensaver.jsx - stays mounted at all times so the CSS opacity transition plays both when fading in and out (the video is paused and rewound while not idle, so it doesn't decode 24/7 behind the app). Its video depends on defaultMode: looked up from the current route's content file's screensavers object (see below), keyed by that mode's own key. With the default defaultMode "all" no entry matches, so no video is shown.

ScreensaverProvider and ContentProvider need router context (useConfig() uses useSearchParams), so they're mounted inside <HashRouter> in src/HashRouter.jsx, not in App.jsx.

Scaling (Stage)

The exhibit runs on a small, fixed set of physical devices - a 4K kiosk panel and a 13" iPad - each shown at a real distance/size very different from a normal responsive website, and each needing to render every app (landscape and, for Rad Enablement, portrait) pixel-crisp, never blurry. Stage (src/components/Stage/Stage.jsx) is the single mechanism that makes that work: it renders the whole app inside a fixed-size reference box and scales that box down (transform: scale(), contain-style - never up) to fit whatever real screen it lands on. Because it's a whole-app transform, no component ever needs its own device/breakpoint logic to look right on a different screen - it just needs to be sized in em/rem like anything else, and Stage takes care of the rest. This replaced an earlier per-page calc(1rem / X) + html-root-font-size scheme that fought against @ui-marcom/shared's own independent viewport-width breakpoints and caused cross-component regressions (fixing one component's size would silently break another's) - if you're touching scaling code, that history is worth knowing before reaching for another root-font-size trick.

Two independent axes pick the reference box, both read via useConfig():

  • Device (stageDevice config value, "4k" default or "ipad") - which physical kiosk this is. A 4K panel and an iPad need their own separate landscape width/height pairs (stageRefWidth/stageRefHeight for "4k", stageIpadRefWidth/stageIpadRefHeight for "ipad") rather than a shared one, because the iPad is a 2x Retina display - its CSS point resolution is roughly half its physical pixel count, nothing like a 4K panel's 1:1 mapping. On a real kiosk, this is set once in that device's fixed launch URL, e.g. .../#/?stageDevice=ipad.
  • Orientation - from the current route's PRESENTATION_MODES entry (see "Presentation Modes" above). Portrait routes use the active device's own pair swapped, not a separately-guessed number - "the same panel, physically rotated" only holds true within one device, so a 4K-panel-in-portrait and an iPad-in-portrait get different reference boxes even though both are "portrait".

What this means when you're building or editing a slide:

  • Size everything in em/rem, never px, vw, or vh. vw/vh resolve against the real browser viewport and ignore Stage's transform entirely, so they silently size against the wrong box the moment the scale factor isn't 1 - use % against a sized ancestor instead.
  • The iPad's reference canvas is proportionally narrower/shorter than the 4K one (different aspect ratio, not just a smaller version of the same shape), so content tuned only by eye against the 4K canvas can still overflow on the iPad even after Stage's own scaling. .rad-page carries an iPad-only content-density correction ([data-stage-device="ipad"] .rad-page { font-size: calc(1em / ...) }, RadEnablement.scss) for exactly this - a new page that turns out to run on both devices may need the same pattern.
  • For any element whose size must never exceed the space it's actually in (rather than just being smaller than average) - a ring, a card, anything that would visibly clip if the density correction alone isn't enough - don't hand-tune another fixed em guess. Cap it with CSS Container Queries instead: container-type: size on the sizing ancestor, then min(<original em size>, <N>cqmin/cqh>) on the thing that must fit. SelectWidget.scss/SelectWidget.jsx (the ring/button picker) is the worked example for a fixed-shape element with JS-computed positions kept in sync with a CSS-capped size (measure the actual rendered box via useElementSize, src/hooks/elementSize.jsx, rather than assuming the original em value always won); InfoGridWidget.scss is the worked example for a whole content block (textbox + grid) that needs to shrink as one proportional unit to fit whatever vertical space is actually left below the headline, deriving each capped value's cqh number as (that value's original em number ÷ the content's total natural em height) × 100 so every contributor shrinks in the same proportion.
  • Test new/changed slides at both ?stageDevice=4k (default) and ?stageDevice=ipad, in the route's real orientation - Chrome DevTools' device toolbar with a matching viewport size is enough, no physical device needed for a first pass. Match the viewport to the actual reference resolution when testing anything built with cqh/cqw/cqmin (2160x3840 portrait for 4k, 1024x1366 portrait for ipad, see the values above): under a transform: scale() ancestor (which is what Stage always is), Chrome resolves container query units against the container's real, on-screen size, not its pre-transform reference-box size - the same as em/rem only when Stage's scale factor is exactly 1 (i.e. the browser window's actual size matches the reference resolution). A smaller/mismatched test window makes em and cq* values drift apart in a way that isn't representative of the real device and won't reproduce there.
  • Known gap: the iPad density correction fixes most Rad Enablement content, but only SelectWidget and InfoGridWidget have the container-query cap described above so far. A content-heavy slide (long headline + logo row + stat card + quote, say) can still overflow at the bottom on the iPad even though the identical slide fits comfortably on the 4K device - if you hit that, that slide's own elements need the same container-query treatment, not another tweak to the single correction factor.

Content

ContentProvider (src/services/content.jsx) fetches a JSON content file and exposes it via useContent(), so exhibit content can be edited without touching code. Which file it fetches depends on the current route: each entry in PRESENTATION_MODES (src/services/presentationModes.js) names the contentFile its route loads, so every presentation app has its own content file instead of one shared one (rad-enablement -> /data/rad-content.json, value-advisory-services -> /data/vas-content.json; value-partnership has none yet). Already-fetched files are cached in memory, so switching between apps doesn't refetch. The contentFile config value (URL param, no default) overrides the route-based choice, e.g. to test one file against any route.

Fetch failures are surfaced instead of silently blanking the kiosk: useContent() exposes an error, the pages render a visible message, and the provider keeps retrying every 10 s until it succeeds. Render errors anywhere below the routes are caught by an ErrorBoundary (src/components/ErrorBoundary.jsx) that shows a message and reloads the app after 15 s.

Content file structure

A content file has four top-level keys: header (persistent page header), screensavers, stories and views. A view (one slide) renders an array of typed content blocks; a story declares its slide order explicitly:

CODE
{
  "stories": {
    "cancer": {
      "name": "Cancer Screening",
      "header": { "icon": "/assets/images/..." },
      "slides": ["cancer-1", "cancer-2", "cancer-3"]
    }
  },
  "views": {
    "cancer-2": {
      "story": "cancer",
      "thumbnail": "/assets/thumbs/...",
      "elements": [
        { "type": "headline", "text": "What are your challenges?" },
        { "type": "buttonList", "buttons": [{ "id": "cancer-2-1", "label": "..." }] }
      ]
    },
    "cancer-2-1": {
      "story": "cancer",
      "parent": "cancer-2",
      "elements": [ ... ]
    }
  }
}
  • Blocks render in array order; a block can repeat. type selects the element (see "Adding a new element" below); the optional region field ("storyTitle" | "headline" | "content") overrides the element's default layout region. Headlines take an optional level (2 = slide headline, 3 = subheadline).
  • Navigation is derived, never authored: prev/next on root slides follow the story's slides array, a sub view (parent field) goes back to its parent and forward to the slide after it, and the first/last slide connect to the story hub. Standalone views outside stories ("rad-intro") chain via nextView.
  • scripts/migrate-content.mjs converts files from the legacy format (elements object, id-prefix conventions, nextView chains) to this one.

Validation: npm run validate:content checks every content file against a JSON schema (skeleton in schemas/content.schema.json, per-element block schemas contributed by src/components/elements/*/schema.js) and verifies all view references (slides, story, parent, button/option targets) - both fail the run. Once public/assets is synced, it additionally warns (doesn't fail) about referenced asset files that don't exist yet - develop is a preview environment, so content still being produced in Drive must not block a deploy; a missing video/image just degrades visibly at runtime instead (stuck "Loading video...", broken image). CI runs the full check on every branch pipeline, and the develop image build re-runs the asset check right after the Google Drive sync.

Adding a new element

Each element folder under src/components/elements/ owns its whole contract, aggregated in elements/registry.js:

  • schema.js - block type, default layout region and the JSON-schema properties/required of the block (pure data, also used by the content validation)
  • <Name>.jsx + <Name>.scss - the component
  • index.js - the registry definition: schema + component + adapter (maps a raw content block to the component's props)

Register the definition in ELEMENT_REGISTRY (registry.js) and its schema in ELEMENT_SCHEMAS (schemas.js - node-importable, so it can't live in the registry), and the renderer, layout and validation all pick the new element up.

Each content file also carries its own screensavers entry (see above), keyed by that mode's own PRESENTATION_MODES key:

CODE
{
  "screensavers": {
    "rad-enablement": "/assets/videos/ra_screensaver.mp4"
  }
}

Asset paths in content files are authored root-absolute (/assets/...) and resolved against Vite's base once at load time (src/utils/resolveAssetPath.js), so subpath deployments (e.g. the test server) work without touching the JSONs.

Assets & Offline

Media assets live in Google Drive and are mirrored into public/assets (gitignored): locally via the Docker-based sync service in sync/ (one-shot or continuous watch mode), and on the dev server via the same image running as the sync container in compose.develop.yml (see "Deployment" below). Either way, public/assets/the mounted assets/ directory is never baked into a Docker image (.dockerignore) - the develop deployment serves it from a volume so media can change without a rebuild; production images (main) ship with no bundled media at all today.

Runtime images are served as right-sized WebP. scripts/optimize-images.mjs (run with node scripts/optimize-images.mjs, uses sharp) converts new PNG slide thumbnails (to 270px WebP) and the large title backgrounds (max 2560px WebP), and moves the PNG masters to asset-src/ (gitignored, not served - keep them, public/assets is gitignored too, so the masters exist nowhere else). Content JSON references must point at the .webp files.

Offline support: the service worker (src/service-worker/service-worker.js, configured in vite.config.js via injectManifest.globPatterns) precaches the bundle, fonts and content JSONs at install time, so a kiosk keeps working through network loss for everything except media it hasn't fetched yet. Since assets aren't present at build time on develop, none of them end up in that precache manifest either way - instead, a StaleWhileRevalidate runtime route (matching by file extension, with ETag/Last-Modified conditional requests) fetches and caches each asset the first time it's requested, then keeps it fresh against the sync container's live copy. Note the size cap (maximumFileSizeToCacheInBytes) is raised to cover the ~9.4 MB screensaver videos, relevant wherever assets are precached (e.g. a production build with bundled media).

Kiosk hardening: pinch/double-tap zoom, text selection, long-press callouts, image dragging and overscroll are disabled globally (index.html viewport meta + src/styles/index.scss); the context menu is suppressed in production (App.jsx).

External Tabs / Windows

TabProvider/useTabs (src/services/tabs.jsx) opens external links in a separate tab/window, keyed by URL - opening the same URL twice focuses the existing tab instead of spawning a new one, and closeTab closes it again. Useful for links that should leave the kiosk view without navigating the app itself away from its route. (Currently has no consumer in the UI - kept for upcoming features, same as the hold-to-reload helper src/components/Reload.jsx.)

Tracking

src/services/analytics/analytics.js initializes a singleton AnalyticsSDK (vendored in AnalyticsSDK.js from the shared messe-tracking client) that implicitly captures clicks/touches/keyups and route changes (via the History API), plus an explicit useAnalytics() API (trackEvent, trackPageView, trackHotspot, trackSlideOrStory) for UI states that don't trigger navigation. Events are queued in IndexedDB and batch-POSTed to an AWS backend. Configure via .env:

  • VITE_ANALYTICS_EXPONAT_ID - exponat/booth id sent with every event; leave empty to disable tracking entirely
  • VITE_ANALYTICS_API_KEY - AWS API key (x-api-key) for the analytics backend; leave empty to disable tracking entirely
  • VITE_ANALYTICS_ENDPOINT - full HTTPS endpoint URL the SDK POSTs event batches to

GeneralPageTracking (src/services/tracking/GeneralPageTracking.jsx) keeps document.title in sync with the current route (e.g. Rad Enablement - cancer-2); page-view tracking itself no longer needs an explicit call there, since the SDK's History API listener picks up every route change automatically.

Cacheable Video

<CacheableVideo> (src/components/CacheableVideo.jsx) is a drop-in replacement for <video src="...">. A plain <video> tag triggers HTTP range requests, which the service worker's Cache API can't store - this component fetches the file as a full response instead and hands the video element a local blob: URL, so it becomes cacheable/offline-capable.

Documentation (JSDoc)

Code is documented with standard JSDoc comments. Generate a browsable site:

CODE
npm run docs
npm run docs:serve   # Pagefind (search) needs to be served over HTTP, not opened as a file

Output goes to docs/ (gitignored, regenerate as needed). Add /** ... */ doc comments above exported functions/hooks to have them picked up - a plain /* ... */ comment is treated as file-level prose and ignored by the generator.

Always-on docs site: every push to develop also builds and deploys the current docs as a static site to https://<project-name>-docs.dev.clck.de (this project: https://digital-exhibit-2026-docs.dev.clck.de) - a standing bookmark that's always up to date with develop, independent of the app deploy. Pipeline: build_docs_develop (runs npm run docs, uploads docs/ as an artifact) -> build_docker_docs_develop (packages it via Dockerfile.docs, a minimal nginx image, into <registry>:docs-dev-<sha> / <registry>:docs-develop) -> deploy_develop starts it as the docs service in compose.develop.yml, routed by Traefik under its own subdomain (DOCS_HOST, override via the DOCS_HOST_OVERRIDE CI/CD variable) so it keeps working even if the app container is redeploying.

Testing

CODE
npm run test      # headless
npm run test:ui   # interactive UI mode

Playwright (playwright.config.js) starts the Vite dev server itself (webServer, reused if one is already running) and runs against Chromium, Firefox and WebKit. tests/smoke.spec.js covers the app selection, a content-driven app's intro/navigation flow, story/sub-view branching and the unknown-view-id redirect - deliberately small, since content-authoring mistakes are caught much earlier by npm run validate:content (see "Content" above).

Linting

CODE
npm run lint       # eslint + stylelint
npm run lint:fix   # same, with autofix

Deployment

Deployment to Develop Environment (dev.clck.de)

Every push to the develop branch deploys a full Docker-based preview to https://<project-name>.dev.clck.de (this project: https://digital-exhibit-2026.dev.clck.de).

How it works:

  1. Content validation - sync_assets_develop runs a one-shot rclone sync that mirrors the Google Drive export folder into public/assets (same mechanism as the local sync service), then runs validate-content against it. This is for CI validation only: public/assets is excluded from the image (.dockerignore) and never baked in, on develop or elsewhere - see step 3.
  2. Docker build & push - the app image is built like the production one (Dockerfile), and a small sync image (sync/Dockerfile.sync) is built alongside it. Both are pushed to the GitLab registry (dev-<short-sha>/develop and sync-dev-<short-sha>/sync-develop).
  3. Deploy - the pipeline copies compose.develop.yml plus a generated .env (including GDRIVE_TOKEN/GDRIVE_FOLDER_NAME) to the dev server via SSH and runs docker compose up -d. That starts the sync container alongside app: it continuously mirrors Google Drive into a host directory (./assets, every 10 minutes by default), which app mounts read-only per subfolder (assets/images, assets/thumbs, assets/videos, assets/icons - not the whole assets/ dir, since Vite's own hashed JS/CSS/font chunks also live directly under dist/assets/). The server runs a Traefik reverse proxy with a wildcard DNS entry *.dev.clck.de: each project gets its own subdomain and an automatic Let's Encrypt certificate - no server configuration needed per project.

Updating content without a code change:

New or changed media in the Drive folder shows up on its own, no pipeline run needed - the sync container picks it up on its next interval (default 10 min, override via the SYNC_INTERVAL_SECONDS CI/CD variable). Content JSON changes still need a commit/push, since those are bundled into the image (see "Assets & Offline" above for how the service worker picks up both without a client-side rebuild).

Required CI/CD variables (Settings → CI/CD → Variables):

VariableTypeDescription
APPLICATION_DEV_SERVERVariableHostname/IP of the dev server
APPLICATION_DEV_USERVariableSSH user (optional, defaults to ubuntu)
APPLICATION_DEV_SSH_PRIVATE_KEYVariablePrivate SSH key for the deploy user (unmasked - multiline; raw, base64 and File type all work)
DOT_ENV_DEVELOP_FILEFileContent of the .env used for the dev build
DEV_HOST_OVERRIDEVariableOptional: full hostname if it should differ from <project-name>.dev.clck.de
DOCS_HOST_OVERRIDEVariableOptional: full hostname for the docs site if it should differ from <project-name>-docs.dev.clck.de
GDRIVE_TOKENVariablerclone OAuth token - the full JSON object ({"access_token":...}), without surrounding quotes
GDRIVE_FOLDER_NAMEVariableSource folder path in Google Drive
GDRIVE_TEAM_DRIVE_IDVariableOptional: Shared Drive ID

Additionally a Deploy token named exactly gitlab-deploy-token with read_registry scope must exist (Settings → Repository → Deploy tokens) - the dev server uses it to pull images from the registry.

On the server (one-time setup): Docker + Compose, the external network proxy (docker network create proxy) and the Traefik stack. Each project lives in ~/apps/<project-name>/ (created by the pipeline); Traefik picks new containers up automatically via Docker labels defined in compose.develop.yml.

Deployment to Test Environment

Development builds are automatically deployed to a test environment upon merging a Merge Request into the test branch.

Each successful deployment creates a unique release accessible via a dedicated URL.

Accessing Your Development Build:

You can find your build at: https://test.click-solutions.com/clck/project-browser/ or https://test.click-solutions.com/<gitlab-group>/<project-name>/<pipeline-id>_<short-commit-sha>

  • <gitlab-group>/<project-name>: This path is derived from your project's path in GitLab.
  • <pipeline-id>_<short-commit-sha>: This forms the unique release name, combining the pipeline ID and the short commit hash.
  • The pipeline itself prints out the url of your deployment, too

Customizing the Deployment Path:

You can override the default <gitlab-group>/<project-name> path by setting the CI/CD variable DEPLOY_PATH_OVERRIDE in your project's settings (e.g., siag/hpresenter_supertest). The URL will then use this custom path.

Handling Environment Variables:

If your application uses environment variables (e.g., via a .env file), define them as a CI/CD variable named DOT_ENV_TEST_FILE. This variable must be configured as a 'File' type, and its content should be the content of your .env file. The variable VITE_BASE_URL will be overwritten by the pipeline.

Release Management:

  • The release name is automatically generated as release_<pipeline-id>_<short-commit-sha>.
  • The test environment retains the last three successful builds.
  • You can manually trigger a pipeline for any commit to redeploy a specific version.

Ready-to-use Docker images

Development builds automatically build a Docker image pushed to the GitLab registry when merged to main.

  • The image name is automatically generated as gitlab.clacks.de:5050/<group>/<repo-name>:<COMMIT_SHORT_SHA>.
  • The release image name is automatically generated as gitlab.clacks.de:5050/<group>/<repo-name>:latest, e.g. gitlab.clacks.de:5050/siag/react-boilerplate-siag-exhibitions:latest
  • You can manually trigger a pipeline for any commit to redeploy a specific version.

Using images on-site:

  1. Define environment variables for local spin-up in .env
  2. Check compose.yml and change the image in the app service section
  3. Login via docker login gitlab.clacks.de:5050 -u gitlab-ext (find the "Access token" field in Keeper by searching gitlab-ext)
  4. Run docker-compose up -d app to start the app service, served via nginx on the configured port