update
diff --git a/charts/openproject/templates/NOTES.txt b/charts/openproject/templates/NOTES.txt
new file mode 100644
index 0000000..bae4286
--- /dev/null
+++ b/charts/openproject/templates/NOTES.txt
@@ -0,0 +1,11 @@
+Thank you for installing OpenProject 🎉
+
+{{- if .Values.ingress.enabled }}
+You can access it via http{{ if .Values.ingress.tls }}s{{ end }}://{{ .Values.ingress.host }}{{ .Values.ingress.path }}
+{{- end }}
+
+Summary:
+--------
+OpenProject: {{ .Values.image.tag }}
+PostgreSQL: {{ if .Values.postgresql.bundled }}{{ .Values.postgresql.image.tag }}{{ else }}external{{ end }}
+Memcached: {{ if .Values.memcached.bundled }}{{ .Values.memcached.image.tag }}{{ else }}external{{ end }}
diff --git a/charts/openproject/templates/_helpers.tpl b/charts/openproject/templates/_helpers.tpl
new file mode 100644
index 0000000..0a6e284
--- /dev/null
+++ b/charts/openproject/templates/_helpers.tpl
@@ -0,0 +1,152 @@
+{{/*
+Returns the OpenProject image to be used including the respective registry and image tag.
+*/}}
+{{- define "openproject.image" -}}
+{{ .Values.image.registry }}/{{ .Values.image.repository }}{{ if .Values.image.sha256 }}@sha256:{{ .Values.image.sha256 }}{{ else }}:{{ .Values.image.tag }}{{ end }}
+{{- end -}}
+
+{{/*
+Returns the OpenProject image pull secrets, if any are defined
+*/}}
+{{- define "openproject.imagePullSecrets" -}}
+{{- if or .Values.imagePullSecrets .Values.global.imagePullSecrets }}
+imagePullSecrets:
+  {{- range (coalesce .Values.imagePullSecrets .Values.global.imagePullSecrets) }}
+  - name: "{{ . }}"
+  {{- end }}
+{{- end }}
+{{- end -}}
+
+{{/*
+Yields the configured container security context if enabled.
+
+Allows writing to the container file system in development mode
+This way the OpenProject container works without mounted tmp volumes
+which may not work correctly in local development clusters.
+*/}}
+{{- define "openproject.containerSecurityContext" }}
+{{- if .Values.containerSecurityContext.enabled }}
+securityContext:
+  {{-
+    mergeOverwrite
+      (omit .Values.containerSecurityContext "enabled" | deepCopy)
+      (dict "readOnlyRootFilesystem" (and
+        (not .Values.develop)
+        (get .Values.containerSecurityContext "readOnlyRootFilesystem")
+      ))
+    | toYaml
+    | nindent 2
+  }}
+{{- end }}
+{{- end }}
+
+{{/* Yields the configured pod security context if enabled. */}}
+{{- define "openproject.podSecurityContext" }}
+{{- if .Values.podSecurityContext.enabled }}
+securityContext:
+  {{ omit .Values.podSecurityContext "enabled" | toYaml | nindent 2 | trim }}
+{{- end }}
+{{- end }}
+
+
+{{- define "openproject.useTmpVolumes" -}}
+{{- if ne .Values.openproject.useTmpVolumes nil -}}
+  {{- .Values.openproject.useTmpVolumes -}}
+{{- else -}}
+  {{- (not .Values.develop) -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "openproject.tmpVolumeMounts" -}}
+{{- if eq (include "openproject.useTmpVolumes" .) "true" }}
+- mountPath: /tmp
+  name: tmp
+- mountPath: /app/tmp
+  name: app-tmp
+{{- end }}
+{{- end -}}
+
+{{- define "openproject.tmpVolumeSpec" -}}
+{{- if eq (include "openproject.useTmpVolumes" .) "true" }}
+- name: tmp
+  # we can't use emptyDir due to the sticky bit issue
+  # see: https://github.com/kubernetes/kubernetes/issues/110835
+  ephemeral:
+    volumeClaimTemplate:
+      spec:
+        accessModes: ["ReadWriteOnce"]
+        resources:
+          requests:
+            storage: {{ .Values.openproject.tmpVolumesStorage }}
+- name: app-tmp
+  # we can't use emptyDir due to the sticky bit / world writable issue
+  # see: https://github.com/kubernetes/kubernetes/issues/110835
+  ephemeral:
+    volumeClaimTemplate:
+      spec:
+        accessModes: ["ReadWriteOnce"]
+        resources:
+          requests:
+            storage: {{ .Values.openproject.tmpVolumesStorage }}
+{{- end }}
+{{- end -}}
+
+{{- define "openproject.envFrom" -}}
+- secretRef:
+    name: {{ include "common.names.fullname" . }}-core
+{{- if .Values.openproject.oidc.enabled }}
+- secretRef:
+    name: {{ include "common.names.fullname" . }}-oidc
+{{- end }}
+{{- if .Values.s3.enabled }}
+- secretRef:
+    name: {{ include "common.names.fullname" . }}-s3
+{{- end }}
+{{- if eq .Values.openproject.cache.store "memcache" }}
+- secretRef:
+    name: {{ include "common.names.fullname" . }}-memcached
+{{- end }}
+{{- if .Values.environment }}
+- secretRef:
+    name: {{ include "common.names.fullname" . }}-environment
+{{- end }}
+{{- if .Values.openproject.extraEnvVarsSecret }}
+- secretRef:
+    name: {{ .Values.openproject.extraEnvVarsSecret }}
+{{- end }}
+{{- if .Values.openproject.oidc.extraOidcSealedSecret }}
+- secretRef:
+    name: {{ .Values.openproject.oidc.extraOidcSealedSecret }}
+{{- end }}
+{{- end }}
+
+{{- define "openproject.env" -}}
+{{- if .Values.egress.tls.rootCA.fileName }}
+- name: SSL_CERT_FILE
+  value: "/etc/ssl/certs/custom-ca.pem"
+{{- end }}
+{{- if .Values.postgresql.auth.existingSecret }}
+- name: OPENPROJECT_DB_PASSWORD
+  valueFrom:
+    secretKeyRef:
+      name: {{ .Values.postgresql.auth.existingSecret }}
+      key: {{ .Values.postgresql.auth.secretKeys.userPasswordKey }}
+{{- else if .Values.postgresql.auth.password }}
+- name: OPENPROJECT_DB_PASSWORD
+  value: {{ .Values.postgresql.auth.password }}
+{{- else }}
+- name: OPENPROJECT_DB_PASSWORD
+  valueFrom:
+    secretKeyRef:
+      name: {{ include "common.names.dependency.fullname" (dict "chartName" "postgresql" "chartValues" .Values.postgresql "context" $) }}
+      key: {{ .Values.postgresql.auth.secretKeys.userPasswordKey }}
+{{- end }}
+{{- end }}
+
+{{- define "openproject.envChecksums" }}
+# annotate pods with env value checksums so changes trigger re-deployments
+{{/* If I knew how to map and reduce a range in helm I would do that and use a single checksum. But here we are. */}}
+{{- range $suffix := list "core" "memcached" "oidc" "s3" "environment" }}
+checksum/env-{{ $suffix }}: {{ include (print $.Template.BasePath "/secret_" $suffix ".yaml") $ | sha256sum }}
+{{- end }}
+{{- end }}
diff --git a/charts/openproject/templates/ingress.yaml b/charts/openproject/templates/ingress.yaml
new file mode 100644
index 0000000..6695da0
--- /dev/null
+++ b/charts/openproject/templates/ingress.yaml
@@ -0,0 +1,33 @@
+{{- if .Values.ingress.enabled -}}
+---
+apiVersion: {{ include "common.capabilities.ingress.apiVersion" . }}
+kind: Ingress
+metadata:
+  name: {{ include "common.names.fullname" . }}
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+  {{- with .Values.ingress.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  {{- if .Values.ingress.ingressClassName }}
+  ingressClassName: {{ .Values.ingress.ingressClassName }}
+  {{- end }}
+  {{- if .Values.ingress.tls.enabled }}
+  tls:
+    - hosts:
+        - {{ .Values.ingress.host | quote }}
+      secretName: "{{ .Values.ingress.tls.secretName }}"
+  {{- end }}
+  rules:
+    - host: {{ .Values.ingress.host | quote }}
+      http:
+        paths:
+          - path: {{ .Values.ingress.path }}
+            {{- if eq "true" (include "common.ingress.supportsPathType" .) }}
+            pathType: {{ .Values.ingress.pathType }}
+            {{- end }}
+            backend: {{- include "common.ingress.backend" (dict "serviceName" (include "common.names.fullname" $) "servicePort" "http" "context" $) | nindent 14 }}
+...
+{{- end }}
diff --git a/charts/openproject/templates/persistentvolumeclaim.yaml b/charts/openproject/templates/persistentvolumeclaim.yaml
new file mode 100644
index 0000000..8350d20
--- /dev/null
+++ b/charts/openproject/templates/persistentvolumeclaim.yaml
@@ -0,0 +1,24 @@
+{{- if .Values.persistence.enabled }}
+{{- if not .Values.persistence.existingClaim }}
+---
+apiVersion: "v1"
+kind: "PersistentVolumeClaim"
+metadata:
+  name: {{ include "common.names.fullname" . }}
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+  {{- with .Values.persistence.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  accessModes: {{ .Values.persistence.accessModes }}
+  {{- if .Values.persistence.storageClassName }}
+  storageClassName: {{ .Values.persistence.storageClassName }}
+  {{- end }}
+  resources:
+    requests:
+      storage: {{ .Values.persistence.size | quote }}
+...
+{{- end }}
+{{- end }}
diff --git a/charts/openproject/templates/secret_core.yaml b/charts/openproject/templates/secret_core.yaml
new file mode 100644
index 0000000..d9eb32a
--- /dev/null
+++ b/charts/openproject/templates/secret_core.yaml
@@ -0,0 +1,31 @@
+---
+apiVersion: "v1"
+kind: "Secret"
+metadata:
+  name: "{{ include "common.names.fullname" . }}-core"
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+stringData:
+  {{- if .Values.postgresql.bundled }}
+  DATABASE_HOST: {{ printf "%s-postgresql.%s.svc.%s" .Release.Name .Release.Namespace .Values.clusterDomain | quote }}
+  DATABASE_PORT: "{{ .Values.postgresql.primary.service.ports.postgresql }}"
+  DATABASE_URL: "postgresql://{{ .Values.postgresql.auth.username }}@{{ include "common.names.dependency.fullname" (dict "chartName" "postgresql" "chartValues" .Values.postgresql "context" $) }}:{{ .Values.postgresql.primary.service.ports.postgresql }}/{{ .Values.postgresql.auth.database }}"
+  {{- else }}
+  DATABASE_HOST: "{{ .Values.postgresql.connection.host }}"
+  DATABASE_PORT: "{{ .Values.postgresql.connection.port }}"
+  DATABASE_URL: "postgresql://{{ .Values.postgresql.auth.username }}@{{ .Values.postgresql.connection.host }}:{{ .Values.postgresql.connection.port }}/{{ .Values.postgresql.auth.database }}"
+  {{- end }}
+  OPENPROJECT_SEED_ADMIN_USER_PASSWORD: {{ .Values.openproject.admin_user.password | quote }}
+  OPENPROJECT_SEED_ADMIN_USER_PASSWORD_RESET: {{ .Values.openproject.admin_user.password_reset | quote }}
+  OPENPROJECT_SEED_ADMIN_USER_NAME: {{ .Values.openproject.admin_user.name | quote }}
+  OPENPROJECT_SEED_ADMIN_USER_MAIL: {{ .Values.openproject.admin_user.mail | quote }}
+  OPENPROJECT_HTTPS: {{ (.Values.develop | ternary "false" .Values.openproject.https) | quote }}
+  OPENPROJECT_SEED_LOCALE: {{ .Values.openproject.seed_locale | quote }}
+  {{- if .Values.ingress.enabled }}
+  OPENPROJECT_HOST__NAME: {{ .Values.openproject.host | default .Values.ingress.host | quote }}
+  {{- end }}
+  OPENPROJECT_HSTS: {{ .Values.openproject.hsts | quote }}
+  OPENPROJECT_RAILS__CACHE__STORE: {{ .Values.openproject.cache.store | quote }}
+  OPENPROJECT_RAILS__RELATIVE__URL__ROOT: {{ .Values.openproject.railsRelativeUrlRoot | default "" | quote }}
+  POSTGRES_STATEMENT_TIMEOUT: {{ .Values.openproject.postgresStatementTimeout | quote }}
+...
diff --git a/charts/openproject/templates/secret_environment.yaml b/charts/openproject/templates/secret_environment.yaml
new file mode 100644
index 0000000..ab08a67
--- /dev/null
+++ b/charts/openproject/templates/secret_environment.yaml
@@ -0,0 +1,15 @@
+{{- if .Values.environment }}
+---
+apiVersion: "v1"
+kind: "Secret"
+metadata:
+  name: "{{ include "common.names.fullname" . }}-environment"
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+stringData:
+  # Additional environment variables
+  {{- range $key, $value := .Values.environment }}
+  {{ $key }}: {{ $value | quote }}
+  {{- end }}
+...
+{{- end }}
diff --git a/charts/openproject/templates/secret_memcached.yaml b/charts/openproject/templates/secret_memcached.yaml
new file mode 100644
index 0000000..a6a7dc6
--- /dev/null
+++ b/charts/openproject/templates/secret_memcached.yaml
@@ -0,0 +1,16 @@
+{{- if eq .Values.openproject.cache.store "memcache" }}
+---
+apiVersion: "v1"
+kind: "Secret"
+metadata:
+  name: "{{ include "common.names.fullname" . }}-memcached"
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+stringData:
+  {{- if .Values.memcached.bundled }}
+  OPENPROJECT_CACHE__MEMCACHE__SERVER: "{{ .Release.Name }}-memcached:11211"
+  {{- else }}
+  OPENPROJECT_CACHE__MEMCACHE__SERVER: "{{ .Values.memcached.connection.host }}:{{.Values.memcached.connection.port }}"
+  {{- end }}
+...
+{{- end }}
diff --git a/charts/openproject/templates/secret_oidc.yaml b/charts/openproject/templates/secret_oidc.yaml
new file mode 100644
index 0000000..03a16a8
--- /dev/null
+++ b/charts/openproject/templates/secret_oidc.yaml
@@ -0,0 +1,32 @@
+{{- if .Values.openproject.oidc.enabled }}
+---
+apiVersion: "v1"
+kind: "Secret"
+metadata:
+  name: "{{ include "common.names.fullname" . }}-oidc"
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+stringData:
+  # OpenID Connect settings
+  {{ $oidc_prefix := printf "OPENPROJECT_OPENID__CONNECT_%s" (upper .Values.openproject.oidc.provider) }}
+  {{ $oidc_prefix }}_DISPLAY__NAME: {{ .Values.openproject.oidc.displayName | quote }}
+  {{ $oidc_prefix }}_HOST: {{ .Values.openproject.oidc.host | quote }}
+  {{/* Fall back to '_' as secret name if the name is not given. This way `lookup` will return null (since secrets with this name will and cannot exist) which it doesn't with an empty string. */}}
+  {{ $secret := (lookup "v1" "Secret" .Release.Namespace (default "_" .Values.openproject.oidc.existingSecret)) | default (dict "data" dict) -}}
+  {{ $oidc_prefix }}_IDENTIFIER: {{
+    default .Values.openproject.oidc.identifier (get $secret.data .Values.openproject.oidc.secretKeys.identifier | b64dec) | quote
+  }}
+  {{ $oidc_prefix }}_SECRET: {{
+    default .Values.openproject.oidc.secret (get $secret.data .Values.openproject.oidc.secretKeys.secret | b64dec) | quote
+  }}
+  {{ $oidc_prefix }}_AUTHORIZATION__ENDPOINT: {{ .Values.openproject.oidc.authorizationEndpoint | quote }}
+  {{ $oidc_prefix }}_TOKEN__ENDPOINT: {{ .Values.openproject.oidc.tokenEndpoint | quote }}
+  {{ $oidc_prefix }}_USERINFO__ENDPOINT: {{ .Values.openproject.oidc.userinfoEndpoint | quote }}
+  {{ $oidc_prefix }}_END__SESSION__ENDPOINT: {{ .Values.openproject.oidc.endSessionEndpoint | quote }}
+  {{ $oidc_prefix }}_SCOPE: {{ .Values.openproject.oidc.scope | quote }}
+  {{- range $key, $value := .Values.openproject.oidc.attribute_map }}
+  {{ $mapping_key := printf "%s_ATTRIBUTE__MAP_%s" $oidc_prefix (upper $key) }}
+  {{ $mapping_key }}: {{ $value | quote }}
+  {{- end }}
+...
+{{- end }}
diff --git a/charts/openproject/templates/secret_s3.yaml b/charts/openproject/templates/secret_s3.yaml
new file mode 100644
index 0000000..354b01e
--- /dev/null
+++ b/charts/openproject/templates/secret_s3.yaml
@@ -0,0 +1,38 @@
+{{- if .Values.s3.enabled }}
+---
+apiVersion: "v1"
+kind: "Secret"
+metadata:
+  name: "{{ include "common.names.fullname" . }}-s3"
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+stringData:
+  OPENPROJECT_ATTACHMENTS__STORAGE: fog
+  OPENPROJECT_FOG_CREDENTIALS_PROVIDER: AWS
+  {{/* Fall back to '_' as secret name if the name is not given. This way `lookup` will return null (since secrets with this name will and cannot exist) which it doesn't with an empty string. */}}
+  {{ $secret := (lookup "v1" "Secret" .Release.Namespace (default "_" .Values.s3.auth.existingSecret)) | default (dict "data" dict) -}}
+  OPENPROJECT_FOG_CREDENTIALS_AWS__ACCESS__KEY__ID: {{
+    default .Values.s3.auth.accessKeyId (get $secret.data .Values.s3.auth.secretKeys.accessKeyId | b64dec) | quote
+  }}
+  OPENPROJECT_FOG_CREDENTIALS_AWS__SECRET__ACCESS__KEY: {{
+    default .Values.s3.auth.secretAccessKey (get $secret.data .Values.s3.auth.secretKeys.secretAccessKey | b64dec) | quote
+  }}
+  {{ if .Values.s3.endpoint -}}
+  OPENPROJECT_FOG_CREDENTIALS_ENDPOINT: {{ .Values.s3.endpoint }}
+  {{- end }}
+  {{ if .Values.s3.host -}}
+  OPENPROJECT_FOG_CREDENTIALS_HOST: {{ .Values.s3.host }}
+  {{- end }}
+  {{ if .Values.s3.port -}}
+  OPENPROJECT_FOG_CREDENTIALS_PORT: "{{ .Values.s3.port }}"
+  {{- end }}
+  OPENPROJECT_FOG_DIRECTORY: {{ .Values.s3.bucketName }}
+  OPENPROJECT_FOG_CREDENTIALS_REGION: {{ .Values.s3.region }}
+  OPENPROJECT_FOG_CREDENTIALS_PATH__STYLE: "{{ .Values.s3.pathStyle }}"
+  OPENPROJECT_FOG_CREDENTIALS_AWS__SIGNATURE__VERSION: "{{ .Values.s3.signatureVersion }}"
+  # remove use_iam_profile fallback after some point
+  OPENPROJECT_FOG_CREDENTIALS_USE__IAM__PROFILE: {{ if or .Values.s3.use_iam_profile .Values.s3.useIamProfile }}"true"{{else}}"false"{{end}}
+  OPENPROJECT_FOG_CREDENTIALS_ENABLE__SIGNATURE__V4__STREAMING: {{ if .Values.s3.enableSignatureV4Streaming }}"true"{{else}}"false"{{end}}
+  OPENPROJECT_DIRECT__UPLOADS: {{ if .Values.s3.directUploads }}"true"{{else}}"false"{{end}}
+...
+{{- end }}
diff --git a/charts/openproject/templates/seeder-job.yaml b/charts/openproject/templates/seeder-job.yaml
new file mode 100644
index 0000000..84c0bc7
--- /dev/null
+++ b/charts/openproject/templates/seeder-job.yaml
@@ -0,0 +1,70 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: {{ include "common.names.fullname" . }}-seeder-{{ now | date "20060102150405" }}
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+  {{- with .Values.seederJob.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  ttlSecondsAfterFinished: 6000
+  template:
+    metadata:
+      labels:
+        {{- include "common.labels.standard" . | nindent 8 }}
+        openproject/process: seeder
+      {{- with .Values.seederJob.annotations }}
+      annotations:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+    spec:
+      {{- include "openproject.imagePullSecrets" . | indent 6 }}
+      {{- include "openproject.podSecurityContext" . | indent 6 }}
+      {{- with .Values.nodeSelector }}
+      nodeSelector:
+        {{ toYaml . | nindent 8 | trim }}
+      {{- end }}
+      volumes:
+        {{- include "openproject.tmpVolumeSpec" . | indent 8 }}
+        {{- if .Values.persistence.enabled }}
+        - name: "data"
+          persistentVolumeClaim:
+            claimName: {{ if .Values.persistence.existingClaim }}{{ .Values.persistence.existingClaim }}{{- else }}{{ include "common.names.fullname" . }}{{- end }}
+        {{- end }}
+      initContainers:
+        - name: check-db-ready
+          image: "{{ .Values.initdb.image.registry }}/{{ .Values.initdb.image.repository }}:{{ .Values.initdb.image.tag }}"
+          imagePullPolicy: {{ .Values.initdb.image.imagePullPolicy }}
+          command: [
+            'sh',
+            '-c',
+            'until pg_isready -h $DATABASE_HOST -p $DATABASE_PORT -U {{ .Values.postgresql.auth.username }}; do echo "waiting for database $DATABASE_HOST:$DATABASE_PORT"; sleep 2; done;'
+          ]
+          envFrom:
+            {{- include "openproject.envFrom" . | nindent 12 }}
+          env:
+            {{- include "openproject.env" . | nindent 12 }}
+          resources:
+            {{- toYaml .Values.initdb.resources | nindent 12 }}
+          {{- include "openproject.containerSecurityContext" . | indent 10 }}
+      containers:
+        - name: seeder
+          image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}{{ if .Values.image.sha256 }}@sha256:{{ .Values.image.sha256 }}{{ else }}:{{ .Values.image.tag }}{{ end }}"
+          imagePullPolicy: {{ .Values.image.imagePullPolicy }}
+          args:
+            - bash
+            - /app/docker/prod/seeder
+          envFrom:
+            {{- include "openproject.envFrom" . | nindent 12 }}
+          env:
+            {{- include "openproject.env" . | nindent 12 }}
+          volumeMounts:
+            {{- include "openproject.tmpVolumeMounts" . | indent 12 }}
+            {{- if .Values.persistence.enabled }}
+            - name: "data"
+              mountPath: "/var/openproject/assets"
+            {{- end }}
+          {{- include "openproject.containerSecurityContext" . | indent 10 }}
+      restartPolicy: OnFailure
diff --git a/charts/openproject/templates/service.yaml b/charts/openproject/templates/service.yaml
new file mode 100644
index 0000000..ac23b71
--- /dev/null
+++ b/charts/openproject/templates/service.yaml
@@ -0,0 +1,31 @@
+---
+{{- if or .Values.service.enabled .Values.ingress.enabled }}
+apiVersion: "v1"
+kind: "Service"
+metadata:
+  name: {{ include "common.names.fullname" . }}
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+spec:
+  type: {{ .Values.service.type }}
+  {{- if .Values.service.sessionAffinity.enabled }}
+  sessionAffinity: "ClientIP"
+  sessionAffinityConfig:
+    clientIP:
+      timeoutSeconds: {{ .Values.service.sessionAffinity.timeoutSeconds }}
+  {{- end }}
+  ports:
+    {{- range $key, $value := .Values.service.ports }}
+    - port: {{ $value.port }}
+      targetPort: {{ $key }}
+      protocol: {{ $value.protocol }}
+      name: {{ $key }}
+      {{- if and (eq $.Values.service.type "NodePort") $value.nodePort }}
+      nodePort: {{ $value.nodePort }}
+      {{- end }}
+    {{- end }}
+  selector:
+    {{- include "common.labels.matchLabels" . | nindent 4 }}
+    openproject/process: web
+{{- end }}
+...
diff --git a/charts/openproject/templates/serviceaccount.yaml b/charts/openproject/templates/serviceaccount.yaml
new file mode 100644
index 0000000..046ad71
--- /dev/null
+++ b/charts/openproject/templates/serviceaccount.yaml
@@ -0,0 +1,14 @@
+{{- if .Values.serviceAccount.create -}}
+---
+apiVersion: "v1"
+kind: "ServiceAccount"
+metadata:
+  name: {{ include "common.names.fullname" . }}
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+  {{- with .Values.serviceAccount.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+...
+{{- end }}
diff --git a/charts/openproject/templates/tests/test-connection.yaml b/charts/openproject/templates/tests/test-connection.yaml
new file mode 100644
index 0000000..bd63462
--- /dev/null
+++ b/charts/openproject/templates/tests/test-connection.yaml
@@ -0,0 +1,21 @@
+---
+apiVersion: "v1"
+kind: "Pod"
+metadata:
+  name: "{{ include "common.names.fullname" . }}-test-connection"
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+  annotations:
+    "helm.sh/hook": test
+spec:
+  containers:
+    - name: "wget"
+      image: "busybox"
+      command: ['wget']
+      args:
+        - '--no-verbose'
+        - '--tries=1'
+        - '--spider'
+        - '{{ include "common.names.fullname" . }}:{{ .Values.service.ports.http.port }}/health_check'
+  restartPolicy: "Never"
+...
diff --git a/charts/openproject/templates/web-deployment.yaml b/charts/openproject/templates/web-deployment.yaml
new file mode 100644
index 0000000..4918558
--- /dev/null
+++ b/charts/openproject/templates/web-deployment.yaml
@@ -0,0 +1,128 @@
+---
+apiVersion: {{ include "common.capabilities.deployment.apiVersion" . }}
+kind: Deployment
+metadata:
+  name: {{ include "common.names.fullname" . }}-web
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+    openproject/process: web
+spec:
+  replicas: {{ .Values.replicaCount }}
+  strategy:
+    type: {{ .Values.strategy.type }}
+  selector:
+    matchLabels:
+      {{- include "common.labels.matchLabels" . | nindent 6 }}
+      openproject/process: web
+  template:
+    metadata:
+      annotations:
+        {{- range $key, $val := .Values.podAnnotations }}
+        {{ $key }}: {{ $val | quote }}
+        {{- end }}
+        {{- include "openproject.envChecksums" . | nindent 8 }}
+      labels:
+        {{- include "common.labels.standard" . | nindent 8 }}
+        openproject/process: web
+    spec:
+      {{- include "openproject.imagePullSecrets" . | indent 6 }}
+      {{- with .Values.affinity }}
+      affinity:
+        {{ toYaml . | nindent 8 | trim }}
+      {{- end }}
+      {{- with .Values.tolerations }}
+      tolerations:
+        {{ toYaml . | nindent 8 | trim }}
+      {{- end }}
+      {{- with .Values.nodeSelector }}
+      nodeSelector:
+        {{ toYaml . | nindent 8 | trim }}
+      {{- end }}
+      {{- include "openproject.podSecurityContext" . | indent 6 }}
+      serviceAccountName: {{ include "common.names.fullname" . }}
+      volumes:
+        {{- include "openproject.tmpVolumeSpec" . | indent 8 }}
+        {{- if .Values.egress.tls.rootCA.fileName }}
+        - name: ca-pemstore
+          configMap:
+            name: "{{- .Values.egress.tls.rootCA.configMap }}"
+        {{- end }}
+        {{- if .Values.persistence.enabled }}
+        - name: "data"
+          persistentVolumeClaim:
+            claimName: {{ if .Values.persistence.existingClaim }}{{ .Values.persistence.existingClaim }}{{- else }}{{ include "common.names.fullname" . }}{{- end }}
+        {{- end }}
+      initContainers:
+        - name: wait-for-db
+          {{- include "openproject.containerSecurityContext" . | indent 10 }}
+          image: {{ include "openproject.image" . }}
+          imagePullPolicy: {{ .Values.image.imagePullPolicy }}
+          envFrom:
+            {{- include "openproject.envFrom" . | nindent 12 }}
+          env:
+            {{- include "openproject.env" . | nindent 12 }}
+          command:
+            - bash
+            - /app/docker/prod/wait-for-db
+      containers:
+        - name: "openproject"
+          {{- include "openproject.containerSecurityContext" . | indent 10 }}
+          image: {{ include "openproject.image" . }}
+          imagePullPolicy: {{ .Values.image.imagePullPolicy }}
+          envFrom:
+            {{- include "openproject.envFrom" . | nindent 12 }}
+          env:
+            {{- include "openproject.env" . | nindent 12 }}
+          command:
+            - bash
+            - /app/docker/prod/web
+          volumeMounts:
+            {{- include "openproject.tmpVolumeMounts" . | indent 12 }}
+            {{- if .Values.persistence.enabled }}
+            - name: "data"
+              mountPath: "/var/openproject/assets"
+            {{- end }}
+            {{- if .Values.egress.tls.rootCA.fileName }}
+            - name: ca-pemstore
+              mountPath: /etc/ssl/certs/custom-ca.pem
+              subPath: {{ .Values.egress.tls.rootCA.fileName }}
+              readOnly: false
+            {{- end }}
+          ports:
+            {{- range $key, $value := .Values.service.ports }}
+            - name: {{ $key }}
+              containerPort: {{ $value.containerPort }}
+              protocol: {{ $value.protocol }}
+            {{- end }}
+          {{- if .Values.probes.liveness.enabled }}
+          livenessProbe:
+            httpGet:
+              path: "{{ .Values.openproject.railsRelativeUrlRoot | default "" }}/health_checks/default"
+              port: 8080
+              httpHeaders:
+                # required otherwise health check will return 404 because health check is done using the Pod IP, which may cause issues with downstream variants
+                - name: Host
+                  value: localhost
+            initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
+            timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
+            periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
+            failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
+            successThreshold: {{ .Values.probes.liveness.successThreshold }}
+          {{- end }}
+          {{- if .Values.probes.readiness.enabled }}
+          readinessProbe:
+            httpGet:
+              path: "{{ .Values.openproject.railsRelativeUrlRoot | default "" }}/health_checks/default"
+              port: 8080
+              httpHeaders:
+                # required otherwise health check will return 404 because health check is done using the Pod IP, which may cause issues with downstream variants
+                - name: Host
+                  value: localhost
+            initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
+            timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
+            periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
+            failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
+            successThreshold: {{ .Values.probes.readiness.successThreshold }}
+          {{- end }}
+          resources:
+            {{- toYaml .Values.resources | nindent 12 }}
diff --git a/charts/openproject/templates/worker-deployment.yaml b/charts/openproject/templates/worker-deployment.yaml
new file mode 100644
index 0000000..3d28d3d
--- /dev/null
+++ b/charts/openproject/templates/worker-deployment.yaml
@@ -0,0 +1,98 @@
+{{- range $workerName, $workerValues := .Values.workers }}
+{{- with $ -}}
+---
+apiVersion: {{ include "common.capabilities.deployment.apiVersion" . }}
+kind: Deployment
+metadata:
+  name: {{ include "common.names.fullname" . }}-worker-{{ $workerName }}
+  labels:
+    {{- include "common.labels.standard" . | nindent 4 }}
+    openproject/process: worker-{{ $workerName }}
+spec:
+  replicas: {{( kindIs "invalid" $workerValues.replicaCount) | ternary .Values.backgroundReplicaCount $workerValues.replicaCount }}
+  strategy:
+    {{ coalesce $workerValues.strategy .Values.strategy | toYaml | nindent 4 }}
+  selector:
+    matchLabels:
+      {{- include "common.labels.matchLabels" . | nindent 6 }}
+      openproject/process: worker-{{ $workerName }}
+  template:
+    metadata:
+      annotations:
+        {{- range $key, $val := .Values.podAnnotations }}
+        {{ $key }}: {{ $val | quote }}
+        {{- end }}
+        {{- include "openproject.envChecksums" . | nindent 8 }}
+      labels:
+        {{- include "common.labels.standard" . | nindent 8 }}
+        openproject/process: worker-{{ $workerName }}
+    spec:
+      {{- include "openproject.imagePullSecrets" . | indent 6 }}
+      {{- with .Values.affinity }}
+      affinity:
+        {{ toYaml . | nindent 8 | trim }}
+      {{- end }}
+      {{- with .Values.tolerations }}
+      tolerations:
+        {{ toYaml . | nindent 8 | trim }}
+      {{- end }}
+      {{- with .Values.nodeSelector }}
+      nodeSelector:
+        {{ toYaml . | nindent 8 | trim }}
+      {{- end }}
+      {{- include "openproject.podSecurityContext" . | indent 6 }}
+      serviceAccountName: {{ include "common.names.fullname" . }}
+      volumes:
+        {{- include "openproject.tmpVolumeSpec" . | indent 8 }}                     
+        {{- if .Values.egress.tls.rootCA.fileName }}
+        - name: ca-pemstore
+          configMap:
+            name: "{{- .Values.egress.tls.rootCA.configMap }}"
+        {{- end }}
+        {{- if .Values.persistence.enabled }}
+        - name: "data"
+          persistentVolumeClaim:
+            claimName: {{ include "common.names.fullname" . }}
+        {{- end }}
+      initContainers:
+        - name: wait-for-db
+          {{- include "openproject.containerSecurityContext" . | indent 10 }}
+          image: {{ include "openproject.image" . }}
+          imagePullPolicy: {{ .Values.image.imagePullPolicy }}
+          envFrom:
+            {{- include "openproject.envFrom" . | nindent 12 }}
+          env:
+            {{- include "openproject.env" . | nindent 12 }}
+          command:
+            - bash
+            - /app/docker/prod/wait-for-db
+      containers:
+        - name: "openproject"
+          {{- include "openproject.containerSecurityContext" . | indent 10 }}
+          image: {{ include "openproject.image" . }}
+          imagePullPolicy: {{ .Values.image.imagePullPolicy }}
+          envFrom:
+            {{- include "openproject.envFrom" . | nindent 12 }}
+          command:
+            - bash
+            - /app/docker/prod/worker
+          env:
+            {{- include "openproject.env" . | nindent 12 }}
+            - name: "QUEUE"
+              value: "{{ $workerValues.queues }}"
+          volumeMounts:
+            {{- include "openproject.tmpVolumeMounts" . | indent 12 }}
+            {{- if .Values.persistence.enabled }}
+            - name: "data"
+              mountPath: "/var/openproject/assets"
+            {{- end }}
+            {{- if .Values.egress.tls.rootCA.fileName }}
+            - name: ca-pemstore
+              mountPath: /etc/ssl/certs/custom-ca.pem
+              subPath: {{ .Values.egress.tls.rootCA.fileName }}
+              readOnly: false
+            {{- end }}
+          resources:
+            {{- coalesce $workerValues.resources .Values.resources | toYaml | nindent 12 }}
+{{- end }}
+{{ end }}
\ No newline at end of file