diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 60c43e9..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,63 +0,0 @@
-FROM ubuntu:24.04
-
-LABEL org.opencontainers.image.authors="Montala Ltd"
-
-ENV DEBIAN_FRONTEND="noninteractive"
-
-RUN apt-get update && apt-get install -y \
- nano \
- imagemagick \
- apache2 \
- subversion \
- ghostscript \
- antiword \
- poppler-utils \
- libimage-exiftool-perl \
- cron \
- postfix \
- wget \
- php \
- php-apcu \
- php-curl \
- php-dev \
- php-gd \
- php-intl \
- php-mysqlnd \
- php-mbstring \
- php-zip \
- libapache2-mod-php \
- ffmpeg \
- libopencv-dev \
- python3-opencv \
- python3 \
- python3-pip \
- && apt-get clean \
- && rm -rf /var/lib/apt/lists/*
-
-RUN sed -i -e "s/upload_max_filesize\s*=\s*2M/upload_max_filesize = 100M/g" /etc/php/8.3/apache2/php.ini \
- && sed -i -e "s/post_max_size\s*=\s*8M/post_max_size = 100M/g" /etc/php/8.3/apache2/php.ini \
- && sed -i -e "s/max_execution_time\s*=\s*30/max_execution_time = 300/g" /etc/php/8.3/apache2/php.ini \
- && sed -i -e "s/memory_limit\s*=\s*128M/memory_limit = 1G/g" /etc/php/8.3/apache2/php.ini
-
-RUN printf '\n\
-\tOptions FollowSymLinks\n\
-\n'\
->> /etc/apache2/sites-enabled/000-default.conf
-
-ADD cronjob /etc/cron.daily/resourcespace
-
-WORKDIR /var/www/html
-
-RUN rm -f index.html \
- && svn co -q https://svn.resourcespace.com/svn/rs/releases/10.7 . \
- && mkdir -p filestore \
- && chmod 777 filestore \
- && chmod -R 777 include/
-
-
-# Copy custom entrypoint script
-COPY entrypoint.sh /entrypoint.sh
-RUN chmod +x /entrypoint.sh
-
-# Start both cron and Apache
-CMD ["/entrypoint.sh"]
diff --git a/NOTICE b/NOTICE
new file mode 100755
index 0000000..6a4112d
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,52 @@
+-------------------------------------------------------------------------------
+Crown Copyright and Upstream Licensing
+Droit d'auteur de la Couronne et licences en amont
+-------------------------------------------------------------------------------
+
+English
+
+Contributions produced by GC and submitted to upstream open-source application
+repositories are provided under the licence of the upstream repository, in
+accordance with its contribution and governance model.
+
+Notwithstanding the above, Crown copyright is retained for the portions of code
+authored by public servants, in the form and state in which those contributions
+are submitted.
+
+This retention does not restrict or alter the rights granted to users under the
+applicable upstream open-source licence.
+
+Français
+
+Les contributions produites par le GC et soumises à des dépôts d'applications
+open source en amont sont diffusées sous la licence du dépôt en amont,
+conformément à ses règles de contribution et de gouvernance.
+
+Nonobstant ce qui précède, le droit d'auteur de la Couronne est conservé pour
+les portions de code rédigées par des fonctionnaires, dans la forme et l'état
+dans lesquels ces contributions sont soumises.
+
+Cette conservation n'a pas pour effet de restreindre ou de modifier les droits
+accordés aux utilisateurs par la licence open source en amont applicable.
+
+-------------------------------------------------------------------------------
+
+English
+
+Portions of this code were authored by the Government of Canada. These
+components, in the form contributed by the GC, are © His Majesty the King in
+Right of Canada, as represented by the Department of Agriculture and Agri-Food
+Canada. This attribution does not modify or replace the applicable licence,
+does not affect permissions, conditions, or disclaimers, and does not
+constitute an endorsement by the Government of Canada of the software, the
+repository, or any deployed application.
+
+Français
+
+Certaines portions de ce code ont été rédigées par le gouvernement du Canada.
+Ces composantes, dans la forme contribuée par le GC, sont © Sa Majesté le Roi
+du chef du Canada, représenté par le ministère de l'Agriculture et
+Agroalimentaire Canada. Cette mention ne modifie ni ne remplace la licence
+applicable, n'affecte pas les autorisations, conditions ou limitations de
+responsabilité, et ne constitue pas une approbation du logiciel, du dépôt ou
+des applications déployées par le gouvernement du Canada.
diff --git a/README.md b/README.md
deleted file mode 100644
index 5a07bf4..0000000
--- a/README.md
+++ /dev/null
@@ -1,6 +0,0 @@
-# resourcespace/docker
-The official Docker image for ResourceSpace. Full build instructions can be found on our [Knowledge Base](https://www.resourcespace.com/knowledge-base/systemadmin/install_docker).
-
-# Installation notes
-* Before building the Docker image, change the db.env file replacing the default "change-me" passwords to secure values.
-* When setting up ResourceSpace ensure you enter "mariadb" as the MySQL server instead of "localhost" and leave the "MySQL binary path" empty.
diff --git a/entrypoint.sh b/entrypoint.sh
deleted file mode 100644
index d1f4ebe..0000000
--- a/entrypoint.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/bash
-
-# Start cron service
-service cron start
-
-# Ensure daily cron jobs are executable
-chmod +x /etc/cron.daily/*
-
-# Start Apache in the foreground (keeps the container alive)
-apachectl -D FOREGROUND
diff --git a/helm/Chart.yaml b/helm/Chart.yaml
new file mode 100755
index 0000000..e91d395
--- /dev/null
+++ b/helm/Chart.yaml
@@ -0,0 +1,16 @@
+apiVersion: v2
+name: resourcespace
+description: >
+ Helm chart for deploying ResourceSpace (DAM) on OpenShift with
+ an in-cluster MariaDB StatefulSet
+type: application
+version: 0.1.0
+appVersion: "10.7"
+keywords:
+ - resourcespace
+ - dam
+ - media
+sources:
+ - https://github.com/resourcespace/docker
+maintainers:
+ - name: your-team
diff --git a/helm/LICENSE b/helm/LICENSE
new file mode 100755
index 0000000..18e0234
--- /dev/null
+++ b/helm/LICENSE
@@ -0,0 +1,40 @@
+MIT License
+
+© His Majesty the King in Right of Canada, as represented by the Minister of
+Agriculture and Agri-Food Canada, 2026.
+
+© Sa Majesté le Roi du chef du Canada, représentée par le ministre de
+l'Agriculture et Agroalimentaire Canada, 2026.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+-------------------------------------------------------------------------------
+
+English
+
+Deployed applications are obtained from their respective upstream projects and
+are governed by their own, separate licenses, including but not limited to
+permissive, copyleft (GPL/AGPL), and proprietary licenses.
+
+Français
+
+Les applications déployées sont obtenues à partir de leurs projets amont
+respectifs et sont régies par leurs propres licences distinctes, y compris,
+sans s'y limiter, des licences permissives, à copyleft (GPL/AGPL) et
+propriétaires.
diff --git a/helm/README.md b/helm/README.md
new file mode 100755
index 0000000..175085a
--- /dev/null
+++ b/helm/README.md
@@ -0,0 +1,146 @@
+# ResourceSpace Helm Chart
+
+Deploys [ResourceSpace](https://www.resourcespace.com/) (Digital Asset Management) on
+OpenShift with:
+
+- **ResourceSpace** web app as a Kubernetes `Deployment`
+- **MariaDB** as an in-cluster `StatefulSet`
+- **External NFS storage** for both the filestore and MariaDB data
+- **OpenShift Route** with edge TLS termination
+
+---
+
+## Prerequisites
+
+| Requirement | Notes |
+|---|---|
+| OpenShift 4.x | Tested on 4.12+ |
+| Helm 3.x | `helm version` |
+| `oc` CLI logged in | `oc whoami` |
+
+---
+
+## Image Build
+ResourceSpace does not publish a pre-built image. The image must be built from the official source repository and pushed to an internal registry
+### Source
+```bash
+git clone git@github.com:resourcespace/docker.git # for SSH clone
+cd docker
+```
+### OpenShift Modifications
+The upstream image runs apache on port 80 as root, which OpenShift's `restricted-v2` SSC does not permit. Three files should be added/modified before building:
+`ports.conf` - Tells Apache to listen on port 8080 instead:
+```bash
+Listen 8080
+```
+`000-default.conf` - vhost on port 8080 with correct directory permissions:
+```bash
+ServerName resourcespace
+
+
+ DocumentRoot /var/www/html
+
+
+ Options FollowSymLinks
+ AllowOverride All
+ Require all granted
+
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+```
+`entrypoint.sh` - skips cron (no `/var/run` write access), redirects apache runtime files to `/tmp` which is writable by any UID:
+```bash
+#!/bin/bash
+set -e
+
+mkdir -p /tmp/apache2/run /tmp/apache2/lock /tmp/apache2/log
+export APACHE_RUN_DIR=/tmp/apache2/run
+export APACHE_LOCK_DIR=/tmp/apache2/lock
+export APACHE_LOG_DIR=/tmp/apache2/log
+export APACHE_PID_FILE=/tmp/apache2/run/apache2.pid
+
+exec apachectl -D FOREGROUND
+```
+`Dockerfile` - additions to upstream:
+```bash
+# Replace Apache configs before the SVN checkout
+COPY ports.conf /etc/apache2/ports.conf
+COPY 000-default.conf /etc/apache2/sites-enabled/000-default.conf
+
+# Make runtime dirs world-writable for arbitrary UID
+RUN mkdir -p /var/run/apache2 /var/lock/apache2 /var/log/apache2 \
+ && chmod -R 777 /var/run/apache2 /var/lock/apache2 /var/log/apache2 \
+ && chmod -R 777 /var/www/html
+
+EXPOSE 8080
+```
+### Build and Push
+```bash
+docker build -t /: .
+docker push /:
+```
+
+## Helm Chart
+### Config before deploying
+In `values.yaml`:
+```yaml
+resourcespace:
+ image: /
+ tag:
+ pullPolicy:
+
+ hostname: # Example: resourcespace.apps.mycluster.example.com
+mariadb:
+ auth:
+ rootPassword: ""
+ database: "resourcespace"
+ username: "resourcespace"
+ password: ""
+```
+### Install
+```bash
+oc new-project # skip if namespace already exists
+helm install resourcespace . -n # run in dir where values.yaml is
+```
+
+### Upgrade
+```bash
+helm upgrade resourcespace . -n # also where values.yaml is
+```
+
+### Uninstall
+```bash
+helm uninstall resourcespace -n
+```
+
+## Run Setup Wizard
+On first deployment, navigating to the route URL shows the ResourceSpace setup wizard
+### Known Issue - Base URL Check
+The wizard validates the base URL by fetching `license.txt` from it. This fails when using the public route URL because the pod cannot route back to itself through the external ingress. **Workaround:** Enter the internal service URL during setup:
+```
+http://resourcespace
+```
+### Database Settings
+| Field | Value |
+|---|---|
+| MySQL server | `resourcespace-mariadb |
+| MySQL port | `3306` |
+| MySQL database | values of `mariadb.auth.database` |
+| MySQL username | values of `mariadb.auth.username` |
+| MySQL password | values of `mariadb.auth.password` |
+| MySQL binary path | (leave empty) |
+| Filestore path | `/var/www/htlm/filestore` |
+
+### Fix Base URL after setup
+After completing the wizard, the internal URL will have been written to `config.php`. Fix it to the public route URL:
+```bash
+oc exec -n deployment/resourcespace -- \
+ sed -i "s||g" \
+ /var/www/html/include/config.php
+
+# Verify
+oc exec -n deployment/resourcespace -- \
+ grep baseurl /var/www/html/include/config.php
+```
\ No newline at end of file
diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl
new file mode 100755
index 0000000..a38387e
--- /dev/null
+++ b/helm/templates/_helpers.tpl
@@ -0,0 +1,70 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "resourcespace.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+*/}}
+{{- define "resourcespace.fullname" -}}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels applied to every resource.
+*/}}
+{{- define "resourcespace.labels" -}}
+helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Selector labels for the ResourceSpace Deployment.
+*/}}
+{{- define "resourcespace.selectorLabels" -}}
+app.kubernetes.io/name: resourcespace
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Selector labels for the MariaDB StatefulSet.
+*/}}
+{{- define "mariadb.selectorLabels" -}}
+app.kubernetes.io/name: mariadb
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Name of the Secret holding database credentials.
+*/}}
+{{- define "mariadb.secretName" -}}
+{{ include "resourcespace.fullname" . }}-mariadb-secret
+{{- end }}
+
+{{/*
+Internal DNS name of the MariaDB Service (used by ResourceSpace config.php).
+*/}}
+{{- define "mariadb.serviceName" -}}
+{{ include "resourcespace.fullname" . }}-mariadb
+{{- end }}
+
+{{/*
+Validate required values are set before deploying.
+*/}}
+{{- define "resourcespace.validateValues" -}}
+{{- if not .Values.mariadb.auth.rootPassword }}
+ {{- fail "ERROR: mariadb.auth.rootPassword must be set. Use --set mariadb.auth.rootPassword=" }}
+{{- end }}
+{{- if not .Values.mariadb.auth.password }}
+ {{- fail "ERROR: mariadb.auth.password must be set. Use --set mariadb.auth.password=" }}
+{{- end }}
+{{- if not .Values.resourcespace.hostname }}
+ {{- fail "ERROR: resourcespace.hostname must be set. Use --set resourcespace.hostname=" }}
+{{- end }}
+{{- if not .Values.resourcespace.image.repository }}
+ {{- fail "ERROR: resourcespace.image.repository must be set. Build the image from https://github.com/resourcespace/docker and push to your registry." }}
+{{- end }}
+{{- end }}
diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml
new file mode 100755
index 0000000..fe893ef
--- /dev/null
+++ b/helm/templates/deployment.yaml
@@ -0,0 +1,134 @@
+# =============================================================================
+# ResourceSpace Deployment
+# =============================================================================
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "resourcespace.fullname" . }}
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ {{- include "resourcespace.selectorLabels" . | nindent 4 }}
+spec:
+ replicas: {{ .Values.resourcespace.replicaCount }}
+ selector:
+ matchLabels:
+ {{- include "resourcespace.selectorLabels" . | nindent 6 }}
+ # Recreate ensures the NFS filestore isn't written to by two pods
+ # simultaneously when replicaCount=1. Change to RollingUpdate only
+ # after validating concurrent NFS access with multiple replicas.
+ strategy:
+ type: Recreate
+ template:
+ metadata:
+ labels:
+ {{- include "resourcespace.labels" . | nindent 8 }}
+ {{- include "resourcespace.selectorLabels" . | nindent 8 }}
+ spec:
+ # OpenShift assigns a UID from the namespace range automatically.
+ # No securityContext set — pods run under restricted-v2 SCC by default.
+ initContainers:
+ - name: wait-for-mariadb
+ image: busybox:1.36
+ command:
+ - sh
+ - -c
+ - |
+ until nc -z {{ include "mariadb.serviceName" . }} 3306; do
+ echo "Waiting for MariaDB..."; sleep 3;
+ done
+ echo "MariaDB is up."
+
+
+ containers:
+ - name: resourcespace
+ image: "{{ .Values.resourcespace.image.repository }}:{{ .Values.resourcespace.image.tag }}"
+ imagePullPolicy: {{ .Values.resourcespace.image.pullPolicy }}
+ ports:
+ - name: http
+ containerPort: 8080
+ protocol: TCP
+ env:
+ # Database connection - consumed by the entrypoint / config.php.
+ # The hostname resolves to the MariaDB ClusterIP Service.
+ - name: RS_DB_HOST
+ value: {{ include "mariadb.serviceName" . | quote }}
+ - name: RS_DB_NAME
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "mariadb.secretName" . }}
+ key: MARIADB_DATABASE
+ - name: RS_DB_USER
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "mariadb.secretName" . }}
+ key: MARIADB_USER
+ - name: RS_DB_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "mariadb.secretName" . }}
+ key: MARIADB_PASSWORD
+ # Base URL - used by ResourceSpace for link generation.
+ - name: RS_BASEURL
+ value: "https://{{ .Values.resourcespace.hostname }}"
+ {{- with .Values.resourcespace.extraEnv }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ volumeMounts:
+ - name: filestore
+ mountPath: /var/www/html/filestore
+ resources:
+ {{- toYaml .Values.resourcespace.resources | nindent 12 }}
+ livenessProbe:
+ httpGet:
+ path: /
+ port: http
+ initialDelaySeconds: 60
+ periodSeconds: 15
+ failureThreshold: 4
+ readinessProbe:
+ httpGet:
+ path: /
+ port: http
+ initialDelaySeconds: 30
+ periodSeconds: 10
+
+ volumes:
+ - name: filestore
+ persistentVolumeClaim:
+ claimName: {{ include "resourcespace.fullname" . }}-filestore-pvc
+
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+
+---
+# =============================================================================
+# ResourceSpace ClusterIP Service
+# Exposed externally via the OpenShift Route below.
+# =============================================================================
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "resourcespace.fullname" . }}
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ {{- include "resourcespace.selectorLabels" . | nindent 4 }}
+spec:
+ type: ClusterIP
+ selector:
+ {{- include "resourcespace.selectorLabels" . | nindent 4 }}
+ ports:
+ - name: http
+ port: 80
+ targetPort: http
\ No newline at end of file
diff --git a/helm/templates/mariadb.yaml b/helm/templates/mariadb.yaml
new file mode 100755
index 0000000..bc8131c
--- /dev/null
+++ b/helm/templates/mariadb.yaml
@@ -0,0 +1,129 @@
+# =============================================================================
+# MariaDB StatefulSet
+# =============================================================================
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: {{ include "resourcespace.fullname" . }}-mariadb
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ {{- include "mariadb.selectorLabels" . | nindent 4 }}
+spec:
+ serviceName: {{ include "mariadb.serviceName" . }}
+ replicas: 1 # MariaDB single-instance; change to 3+ with Galera for HA
+ selector:
+ matchLabels:
+ {{- include "mariadb.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ labels:
+ {{- include "resourcespace.labels" . | nindent 8 }}
+ {{- include "mariadb.selectorLabels" . | nindent 8 }}
+ spec:
+ # No securityContext set — OpenShift assigns a UID from the namespace
+ # range (1001020000–1001029999). MariaDB 11 runs fine as an arbitrary UID.
+ containers:
+ - name: mariadb
+ image: "{{ .Values.mariadb.image.repository }}:{{ .Values.mariadb.image.tag }}"
+ imagePullPolicy: {{ .Values.mariadb.image.pullPolicy }}
+ ports:
+ - name: mysql
+ containerPort: 3306
+ protocol: TCP
+ envFrom:
+ - secretRef:
+ name: {{ include "mariadb.secretName" . }}
+ volumeMounts:
+ - name: mariadb-data
+ mountPath: /var/lib/mysql
+ resources:
+ {{- toYaml .Values.mariadb.resources | nindent 12 }}
+
+ volumes:
+ - name: mariadb-data
+ persistentVolumeClaim:
+ claimName: {{ include "resourcespace.fullname" . }}-mariadb-pvc
+
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+
+---
+# =============================================================================
+# MariaDB PersistentVolumeClaim
+# Declared as a standalone PVC (not a volumeClaimTemplate) so it persists
+# across StatefulSet re-creations
+# =============================================================================
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ include "resourcespace.fullname" . }}-mariadb-pvc
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ annotations:
+ helm.sh/resource-policy: keep
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: {{ .Values.mariadb.persistence.size }}
+ {{- if .Values.persistence.storageClassName }}
+ storageClassName: {{ .Values.persistence.storageClassName | quote }}
+ {{- end }}
+
+---
+# =============================================================================
+# MariaDB headless Service
+# Provides stable DNS: ...svc.cluster.local
+# ResourceSpace connects to the ClusterIP service below, not this one.
+# =============================================================================
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "mariadb.serviceName" . }}-headless
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ {{- include "mariadb.selectorLabels" . | nindent 4 }}
+spec:
+ clusterIP: None
+ selector:
+ {{- include "mariadb.selectorLabels" . | nindent 4 }}
+ ports:
+ - name: mysql
+ port: 3306
+ targetPort: mysql
+
+---
+# =============================================================================
+# MariaDB ClusterIP Service
+# This is the hostname ResourceSpace uses: -mariadb..svc
+# =============================================================================
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "mariadb.serviceName" . }}
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ {{- include "mariadb.selectorLabels" . | nindent 4 }}
+spec:
+ type: ClusterIP
+ selector:
+ {{- include "mariadb.selectorLabels" . | nindent 4 }}
+ ports:
+ - name: mysql
+ port: 3306
+ targetPort: mysql
\ No newline at end of file
diff --git a/helm/templates/pvc-filestore.yaml b/helm/templates/pvc-filestore.yaml
new file mode 100755
index 0000000..ea83e1e
--- /dev/null
+++ b/helm/templates/pvc-filestore.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ include "resourcespace.fullname" . }}-filestore-pvc
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ annotations:
+ # Prevents accidental deletion of the PVC on helm uninstall.
+ # Delete manually if you are sure you no longer need the data.
+ helm.sh/resource-policy: keep
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: {{ .Values.persistence.filestore.size }}
+ {{- if .Values.persistence.storageClassName }}
+ storageClassName: {{ .Values.persistence.storageClassName | quote }}
+ {{- end }}
\ No newline at end of file
diff --git a/helm/templates/route.yaml b/helm/templates/route.yaml
new file mode 100755
index 0000000..2a1e23b
--- /dev/null
+++ b/helm/templates/route.yaml
@@ -0,0 +1,37 @@
+# =============================================================================
+# OpenShift Route
+#
+# Exposes ResourceSpace via the cluster's built-in HAProxy router.
+# TLS is terminated at the edge (router); traffic inside the cluster is HTTP.
+#
+# To switch to a LoadBalancer Service later:
+# 1. Delete or disable this Route.
+# 2. Change the Service type in deployment.yaml to LoadBalancer.
+# 3. Add any cloud/on-prem load balancer annotations to the Service.
+# =============================================================================
+apiVersion: route.openshift.io/v1
+kind: Route
+metadata:
+ name: {{ include "resourcespace.fullname" . }}
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+ {{- include "resourcespace.selectorLabels" . | nindent 4 }}
+spec:
+ host: {{ .Values.resourcespace.hostname | quote }}
+ to:
+ kind: Service
+ name: {{ include "resourcespace.fullname" . }}
+ weight: 100
+ port:
+ targetPort: http
+ tls:
+ termination: {{ .Values.resourcespace.tls.termination }}
+ insecureEdgeTerminationPolicy: {{ .Values.resourcespace.tls.insecureEdgeTerminationPolicy }}
+ {{- if .Values.resourcespace.tls.secretName }}
+ # Custom TLS certificate loaded from a Secret.
+ # The Secret must exist in the same namespace and contain tls.crt / tls.key.
+ externalCertificate:
+ name: {{ .Values.resourcespace.tls.secretName }}
+ {{- end }}
+ wildcardPolicy: None
\ No newline at end of file
diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml
new file mode 100755
index 0000000..e135796
--- /dev/null
+++ b/helm/templates/secret.yaml
@@ -0,0 +1,27 @@
+{{/*
+ Secret holding MariaDB credentials.
+
+ PRODUCTION NOTE: Do not store real passwords in values.yaml committed to git.
+ Use one of:
+ - Sealed Secrets (https://github.com/bitnami-labs/sealed-secrets)
+ - External Secrets Operator + Vault / AWS SSM
+ - `helm install --set mariadb.auth.password=` at deploy time
+
+ To use a pre-existing external secret instead of this generated one,
+ delete or skip this file and update the env references in the Deployment
+ and StatefulSet to point at your own Secret name/keys.
+*/}}
+{{- include "resourcespace.validateValues" . }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "mariadb.secretName" . }}
+ namespace: {{ .Values.global.namespace }}
+ labels:
+ {{- include "resourcespace.labels" . | nindent 4 }}
+type: Opaque
+stringData:
+ MARIADB_ROOT_PASSWORD: {{ .Values.mariadb.auth.rootPassword | quote }}
+ MARIADB_DATABASE: {{ .Values.mariadb.auth.database | quote }}
+ MARIADB_USER: {{ .Values.mariadb.auth.username | quote }}
+ MARIADB_PASSWORD: {{ .Values.mariadb.auth.password | quote }}
\ No newline at end of file
diff --git a/helm/values.yaml b/helm/values.yaml
new file mode 100755
index 0000000..90dd729
--- /dev/null
+++ b/helm/values.yaml
@@ -0,0 +1,108 @@
+# =============================================================================
+# ResourceSpace Helm Chart - values.yaml
+# =============================================================================
+# Override any value with: helm install resourcespace . -f my-overrides.yaml
+# or with: --set key=value on the command line.
+
+# -----------------------------------------------------------------------------
+# Global
+# -----------------------------------------------------------------------------
+global:
+ # Kubernetes namespace to deploy into (must already exist, or use
+ # `oc new-project ` before installing).
+ namespace:
+
+
+# -----------------------------------------------------------------------------
+# ResourceSpace application
+# -----------------------------------------------------------------------------
+resourcespace:
+ image:
+ # Use the official image from Docker Hub, or your internal registry.
+ repository:
+ tag:
+ pullPolicy: Always
+
+
+ # Number of ResourceSpace web pods.
+ # NOTE: ResourceSpace writes files to the filestore volume - keep replicas at
+ # 1 unless you have configured shared ReadWriteMany NFS storage and tested
+ # concurrent access carefully.
+ replicaCount: 1
+
+ # The publicly reachable hostname for the OpenShift Route.
+ # CONFIRM: set this to your actual cluster ingress domain.
+ # Example: resourcespace.apps.mycluster.example.com
+ hostname:
+
+ # TLS for the OpenShift Route.
+ tls:
+ # edge = OpenShift terminates TLS; insecureEdgeTerminationPolicy redirects HTTP→HTTPS.
+ termination: edge
+ insecureEdgeTerminationPolicy: Redirect
+ # Leave secretName empty to use the cluster's default wildcard certificate,
+ # or set it to the name of a TLS Secret in the same namespace.
+ secretName: ""
+
+ # Resource requests/limits for the ResourceSpace pod.
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "256Mi"
+ limits:
+ cpu: "250m"
+ memory: "512Mi"
+
+ # Additional environment variables injected into the ResourceSpace pod.
+ extraEnv: []
+
+# -----------------------------------------------------------------------------
+# MariaDB (in-cluster StatefulSet)
+# -----------------------------------------------------------------------------
+mariadb:
+ image:
+ repository: mariadb
+ tag: "11"
+ pullPolicy: IfNotPresent
+
+ # Database credentials.
+ # SECURITY: move these to an external secret manager (Vault, SealedSecrets)
+ # before running in production. The chart creates a Kubernetes Secret from
+ # these values; never commit real passwords to source control.
+ auth:
+ rootPassword: "" # Required — set with --set mariadb.auth.rootPassword=
+ database: "resourcespace"
+ username: "resourcespace"
+ password: "" # Required — set with --set mariadb.auth.password=
+
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "256Mi"
+ limits:
+ cpu: "250m"
+ memory: "512Mi"
+
+ # Persistent volume for MariaDB data directory (/var/lib/mysql).
+ persistence:
+ size: 20Gi
+
+# -----------------------------------------------------------------------------
+# Persistence
+# -----------------------------------------------------------------------------
+persistence:
+ # storageClassName applies to both the filestore and MariaDB PVCs when using
+ # dynamic provisioning. Leave as "" to use the cluster's default StorageClass.
+ # Run `oc get storageclass` to see what is available on your cluster.
+ storageClassName: ""
+
+ filestore:
+ size: 10Gi
+
+
+# -----------------------------------------------------------------------------
+# Pod scheduling
+# -----------------------------------------------------------------------------
+nodeSelector: {}
+tolerations: []
+affinity: {}
\ No newline at end of file
diff --git a/openshift/000-default.conf b/openshift/000-default.conf
new file mode 100644
index 0000000..225e7e2
--- /dev/null
+++ b/openshift/000-default.conf
@@ -0,0 +1,16 @@
+# OpenShift-compatible default vhost.
+# Replaces /etc/apache2/sites-enabled/000-default.conf in the image build.
+ServerName resourcespace
+
+
+ DocumentRoot /var/www/html
+
+
+ Options FollowSymLinks
+ AllowOverride All
+ Require all granted
+
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
\ No newline at end of file
diff --git a/openshift/Dockerfile b/openshift/Dockerfile
new file mode 100644
index 0000000..5628ae1
--- /dev/null
+++ b/openshift/Dockerfile
@@ -0,0 +1,67 @@
+FROM ubuntu:24.04
+
+LABEL org.opencontainers.image.authors="Montala Ltd"
+
+ENV DEBIAN_FRONTEND="noninteractive"
+
+RUN apt-get update && apt-get install -y \
+ nano \
+ imagemagick \
+ apache2 \
+ subversion \
+ ghostscript \
+ antiword \
+ poppler-utils \
+ libimage-exiftool-perl \
+ cron \
+ postfix \
+ wget \
+ php \
+ php-apcu \
+ php-curl \
+ php-dev \
+ php-gd \
+ php-intl \
+ php-mysqlnd \
+ php-mbstring \
+ php-zip \
+ libapache2-mod-php \
+ ffmpeg \
+ libopencv-dev \
+ python3-opencv \
+ python3 \
+ python3-pip \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN sed -i -e "s/upload_max_filesize\s*=\s*2M/upload_max_filesize = 100M/g" /etc/php/8.3/apache2/php.ini \
+ && sed -i -e "s/post_max_size\s*=\s*8M/post_max_size = 100M/g" /etc/php/8.3/apache2/php.ini \
+ && sed -i -e "s/max_execution_time\s*=\s*30/max_execution_time = 300/g" /etc/php/8.3/apache2/php.ini \
+ && sed -i -e "s/memory_limit\s*=\s*128M/memory_limit = 1G/g" /etc/php/8.3/apache2/php.ini
+
+# OpenShift: replace upstream Apache configs with port-8080 versions
+COPY ports.conf /etc/apache2/ports.conf
+COPY 000-default.conf /etc/apache2/sites-enabled/000-default.conf
+
+ADD cronjob /etc/cron.daily/resourcespace
+
+WORKDIR /var/www/html
+
+RUN rm -f index.html \
+ && svn co -q https://svn.resourcespace.com/svn/rs/releases/10.7 . \
+ && mkdir -p filestore \
+ && chmod 777 filestore \
+ && chmod -R 777 include/
+
+# OpenShift: make all runtime dirs world-writable so an arbitrary UID can write
+RUN mkdir -p /var/run/apache2 /var/lock/apache2 /var/log/apache2 \
+ && chmod -R 777 /var/run/apache2 /var/lock/apache2 /var/log/apache2 \
+ && chmod -R 777 /var/www/html
+
+# OpenShift-patched entrypoint (no cron, port 8080, tmp runtime dirs)
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+EXPOSE 8080
+
+CMD ["/entrypoint.sh"]
\ No newline at end of file
diff --git a/openshift/README.md b/openshift/README.md
new file mode 100644
index 0000000..0c40f80
--- /dev/null
+++ b/openshift/README.md
@@ -0,0 +1,160 @@
+# ResourceSpace Helm Chart
+
+Deploys [ResourceSpace](https://www.resourcespace.com/) (Digital Asset Management) on
+OpenShift with:
+
+- **ResourceSpace** web app as a Kubernetes `Deployment`
+- **MariaDB** as an in-cluster `StatefulSet`
+- **External NFS storage** for both the filestore and MariaDB data
+- **OpenShift Route** with edge TLS termination
+
+---
+
+## Prerequisites
+
+| Requirement | Notes |
+|---|---|
+| OpenShift 4.x | Tested on 4.12+ |
+| Helm 3.x | `helm version` |
+| `oc` CLI logged in | `oc whoami` |
+
+---
+
+## Image Build
+The image should be built from the official source repository and pushed to an available registry
+### Source
+```bash
+git clone git@github.com:resourcespace/docker.git # for SSH clone
+cd docker
+```
+### OpenShift Modifications
+The upstream image runs apache on port 80 as root, which OpenShift's `restricted-v2` SSC does not permit. Three files should be added/modified before building:
+`ports.conf` - Tells Apache to listen on port 8080 instead:
+```bash
+Listen 8080
+```
+`000-default.conf` - vhost on port 8080 with correct directory permissions:
+```bash
+ServerName resourcespace
+
+
+ DocumentRoot /var/www/html
+
+
+ Options FollowSymLinks
+ AllowOverride All
+ Require all granted
+
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+```
+`entrypoint.sh` - skips cron (no `/var/run` write access), redirects apache runtime files to `/tmp` which is writable by any UID:
+```bash
+#!/bin/bash
+set -e
+
+mkdir -p /tmp/apache2/run /tmp/apache2/lock /tmp/apache2/log
+export APACHE_RUN_DIR=/tmp/apache2/run
+export APACHE_LOCK_DIR=/tmp/apache2/lock
+export APACHE_LOG_DIR=/tmp/apache2/log
+export APACHE_PID_FILE=/tmp/apache2/run/apache2.pid
+
+exec apachectl -D FOREGROUND
+```
+`Dockerfile` - additions to upstream:
+```bash
+# Replace Apache configs before the SVN checkout
+COPY ports.conf /etc/apache2/ports.conf
+COPY 000-default.conf /etc/apache2/sites-enabled/000-default.conf
+
+# Make runtime dirs world-writable for arbitrary UID
+RUN mkdir -p /var/run/apache2 /var/lock/apache2 /var/log/apache2 \
+ && chmod -R 777 /var/run/apache2 /var/lock/apache2 /var/log/apache2 \
+ && chmod -R 777 /var/www/html
+
+EXPOSE 8080
+```
+### Build and Push
+```bash
+docker build -t /: .
+docker push /:
+```
+
+## Helm Chart
+### Config before deploying
+In `values.yaml`:
+```yaml
+resourcespace:
+ image: /
+ tag:
+ pullPolicy:
+
+ hostname: # Example: resourcespace.apps.mycluster.example.com
+mariadb:
+ auth:
+ rootPassword: "" # set in advance or at runtime
+ database: "resourcespace"
+ username: "resourcespace"
+ password: "" # set in advance or at runtime
+```
+### Install
+```bash
+oc new-project # skip if namespace already exists
+helm install resourcespace . -n # run in dir where values.yaml is
+
+# install with secret creation
+helm install resourcespace . -n \
+ --set mariadb.auth.rootPassword= \
+ --set mariadb.auth.password=
+```
+
+### Upgrade
+```bash
+helm upgrade resourcespace . -n # also where values.yaml is
+```
+
+### Uninstall
+```bash
+helm uninstall resourcespace -n
+```
+
+## Run Setup Wizard
+
+On first deployment, navigating to the route URL shows the ResourceSpace setup wizard.
+
+### Known Issue — Base URL Check
+
+The wizard validates the base URL by fetching `license.txt` from it. This fails when
+using the public route URL because the pod cannot route back to itself through the
+external ingress. **Workaround:** enter the internal service URL during setup:
+```
+http://resourcespace
+```
+The entrypoint automatically corrects this to the public route URL on every startup
+via the `RS_BASEURL` environment variable — no manual fix needed.
+
+### Database Settings
+
+| Field | Value |
+|---|---|
+| MySQL server | `resourcespace-mariadb` |
+| MySQL port | `3306` |
+| MySQL database | value of `mariadb.auth.database` in values.yaml |
+| MySQL username | value of `mariadb.auth.username` in values.yaml |
+| MySQL password | value of `mariadb.auth.password` in values.yaml |
+| MySQL binary path | *(leave empty)* |
+| Filestore path | `/var/www/html/filestore` |
+
+### Persisting Configuration
+
+After completing the wizard, restart the pod to persist `config.php` and apply the
+correct public route URL automatically:
+
+```bash
+oc rollout restart deployment/resourcespace -n
+```
+
+The entrypoint saves `config.php` to the filestore PVC on first run and restores it
+with the correct `RS_BASEURL` on every subsequent restart.
\ No newline at end of file
diff --git a/openshift/entrypoint.sh b/openshift/entrypoint.sh
new file mode 100644
index 0000000..03b91ae
--- /dev/null
+++ b/openshift/entrypoint.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+mkdir -p /tmp/apache2/run /tmp/apache2/lock /tmp/apache2/log
+export APACHE_RUN_DIR=/tmp/apache2/run
+export APACHE_LOCK_DIR=/tmp/apache2/lock
+export APACHE_LOG_DIR=/tmp/apache2/log
+export APACHE_PID_FILE=/tmp/apache2/run/apache2.pid
+
+CONFIG_PVC="/var/www/html/filestore/config.php"
+CONFIG_DST="/var/www/html/include/config.php"
+
+if [ -f "$CONFIG_PVC" ] && [ -s "$CONFIG_PVC" ]; then
+ # Subsequent starts: restore config and fix baseurl
+ echo "[entrypoint] Restoring config.php from filestore..."
+ cp "$CONFIG_PVC" "$CONFIG_DST"
+ if [ -n "$RS_BASEURL" ]; then
+ echo "[entrypoint] Fixing baseurl to $RS_BASEURL"
+ sed -i "s|^\$baseurl\s*=\s*'[^']*';|\$baseurl = '$RS_BASEURL';|g" "$CONFIG_DST"
+ fi
+else
+ # First run: symlink config.php into the PVC so wizard writes directly there
+ echo "[entrypoint] First run — symlinking config.php to filestore PVC..."
+ rm -f "$CONFIG_DST"
+ ln -s "$CONFIG_PVC" "$CONFIG_DST"
+ echo "[entrypoint] Wizard will write config.php directly to the PVC."
+fi
+
+exec apachectl -D FOREGROUND
\ No newline at end of file
diff --git a/openshift/ports.conf b/openshift/ports.conf
new file mode 100644
index 0000000..ca35c5c
--- /dev/null
+++ b/openshift/ports.conf
@@ -0,0 +1,5 @@
+# OpenShift-compatible Apache port config.
+# Replaces /etc/apache2/ports.conf in the image build.
+# Port 8080 is used instead of 80 because OpenShift's restricted-v2 SCC
+# does not allow binding to ports below 1024.
+Listen 8080
\ No newline at end of file