diff --git a/charts/paperless-ngx/Chart.yaml b/charts/paperless-ngx/Chart.yaml new file mode 100644 index 0000000..c218568 --- /dev/null +++ b/charts/paperless-ngx/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: paperless-ngx +description: Paperless-ngx helm chart for Kubernetes +type: application +version: 0.0.1 +appVersion: "latest" +maintainers: + - name: Richard Tomik + email: no@m.com +keywords: + - productivity + - document-management + - paperless + - paperless-ngx + - ocr +home: https://github.com/rtomik/helm-charts +sources: + - https://github.com/paperless-ngx/paperless-ngx \ No newline at end of file diff --git a/charts/paperless-ngx/NOTES.txt b/charts/paperless-ngx/NOTES.txt new file mode 100644 index 0000000..06f3563 --- /dev/null +++ b/charts/paperless-ngx/NOTES.txt @@ -0,0 +1,92 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "paperless-ngx.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "paperless-ngx.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "paperless-ngx.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "paperless-ngx.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8000 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8000:$CONTAINER_PORT +{{- end }} + +2. Application is accessible at port {{ .Values.service.port }} + +3. Paperless-ngx is configured with: + - Database: PostgreSQL (external) + - Redis: External service + - OCR Language: {{ .Values.config.ocr.language }} + - Time Zone: {{ .Values.config.timeZone }} + +4. External Dependencies Required: + - PostgreSQL server: {{ include "paperless-ngx.postgresql.host" . }}:{{ include "paperless-ngx.postgresql.port" . }} + - Redis server: {{ include "paperless-ngx.redis.host" . }}:{{ include "paperless-ngx.redis.port" . }} + +{{- if or .Values.persistence.data.enabled .Values.persistence.media.enabled .Values.persistence.consume.enabled .Values.persistence.export.enabled }} +5. Persistent Storage: +{{- if .Values.persistence.data.enabled }} + - Data directory: {{ include "paperless-ngx.fullname" . }}-data ({{ .Values.persistence.data.size }}) +{{- end }} +{{- if .Values.persistence.media.enabled }} + - Media directory: {{ include "paperless-ngx.fullname" . }}-media ({{ .Values.persistence.media.size }}) +{{- end }} +{{- if .Values.persistence.consume.enabled }} + - Consume directory: {{ include "paperless-ngx.fullname" . }}-consume ({{ .Values.persistence.consume.size }}) +{{- end }} +{{- if .Values.persistence.export.enabled }} + - Export directory: {{ include "paperless-ngx.fullname" . }}-export ({{ .Values.persistence.export.size }}) +{{- end }} +{{- else }} +5. WARNING: No persistent storage enabled. Data will be lost when pods are restarted. + Enable persistence in values.yaml for production use. +{{- end }} + +{{- if .Values.config.admin.user }} +6. Admin User: {{ .Values.config.admin.user }} + The admin user will be created automatically on first startup. +{{- else }} +6. No admin user configured. You'll need to create a superuser manually: + kubectl exec -it deployment/{{ include "paperless-ngx.fullname" . }} -- python manage.py createsuperuser +{{- end }} + +{{- if or .Values.config.secretKey.existingSecret .Values.postgresql.external.existingSecret .Values.config.admin.existingSecret }} +7. Using external secrets for sensitive information: +{{- if .Values.config.secretKey.existingSecret }} + - Secret key from: {{ .Values.config.secretKey.existingSecret }} +{{- end }} +{{- if .Values.postgresql.external.existingSecret }} + - PostgreSQL password from: {{ .Values.postgresql.external.existingSecret }} +{{- end }} +{{- if .Values.config.admin.existingSecret }} + - Admin credentials from: {{ .Values.config.admin.existingSecret }} +{{- end }} +{{- else }} +7. SECURITY NOTE: For production use, it's recommended to store sensitive data in Kubernetes Secrets. + - Set config.secretKey.existingSecret to use an external secret for the secret key + - Set postgresql.external.existingSecret to use an external secret for database credentials + - Set config.admin.existingSecret to use an external secret for admin credentials +{{- end }} + +{{- if .Values.config.consumer.barcodes.enabled }} +8. Barcode processing is enabled with scanner: {{ .Values.config.consumer.barcodeScanner }} +{{- end }} + +{{- if .Values.config.tika.enabled }} +9. Tika integration is enabled for Office document processing + - Tika endpoint: {{ .Values.config.tika.endpoint }} + - Gotenberg endpoint: {{ .Values.config.tika.gotenbergEndpoint }} +{{- end }} + +For more information about using this Helm chart and Paperless-ngx configuration, +please refer to the README.md file and the official Paperless-ngx documentation. \ No newline at end of file diff --git a/charts/paperless-ngx/readme.md b/charts/paperless-ngx/readme.md new file mode 100644 index 0000000..1a1fd9f --- /dev/null +++ b/charts/paperless-ngx/readme.md @@ -0,0 +1,238 @@ +# Paperless-ngx Helm Chart + +A Helm chart for deploying Paperless-ngx document management system on Kubernetes. + +## Introduction + +This chart deploys [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) on a Kubernetes cluster using the Helm package manager. + +Paperless-ngx is a community-supported supercharged version of paperless: scan, index and archive all your physical documents. + +Source code can be found here: +- https://github.com/rtomik/helm-charts/tree/main/charts/paperless-ngx + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.0+ +- PV provisioner support in the underlying infrastructure +- **External PostgreSQL database** (required) +- **External Redis server** (required) + +## External Dependencies + +This chart requires external PostgreSQL and Redis services. It does not deploy these dependencies to avoid resource conflicts on centralized servers. + +### PostgreSQL Setup +Paperless-ngx requires PostgreSQL 11+ as its database backend. Ensure you have: +- A PostgreSQL database created for Paperless-ngx +- Database credentials configured in values.yaml or via secrets + +### Redis Setup +Redis is required for background task processing. Ensure you have: +- A Redis server accessible from the cluster +- Connection details configured in values.yaml + +## Installing the Chart + +To install the chart with the release name `paperless-ngx`: + +```bash +$ helm repo add paperless-chart https://rtomik.github.io/helm-charts +$ helm install paperless-ngx paperless-chart/paperless-ngx +``` + +Or install directly from this repository: + +```bash +$ git clone https://github.com/rtomik/helm-charts.git +$ cd helm-charts/charts/paperless-ngx +$ helm install paperless-ngx . +``` + +> **Tip**: List all releases using `helm list` + +## Configuration + +The following table lists the configurable parameters and their default values. + +### Global Parameters + +| Name | Description | Value | +|------------------------|-------------------------------------------------------------------------------------|-------| +| `nameOverride` | String to partially override the release name | `""` | +| `fullnameOverride` | String to fully override the release name | `""` | + +### Image Parameters + +| Name | Description | Value | +|-------------------------|--------------------------------------------------------------------------------------|--------------------| +| `image.repository` | Paperless-ngx image repository | `ghcr.io/paperless-ngx/paperless-ngx` | +| `image.tag` | Paperless-ngx image tag | `latest` | +| `image.pullPolicy` | Paperless-ngx image pull policy | `IfNotPresent` | + +### External Dependencies + +| Name | Description | Value | +|----------------------------------------|--------------------------------------------------------------------|-------------------------------------------| +| `postgresql.external.enabled` | Enable external PostgreSQL configuration | `true` | +| `postgresql.external.host` | External PostgreSQL host | `postgresql.default.svc.cluster.local` | +| `postgresql.external.port` | External PostgreSQL port | `5432` | +| `postgresql.external.database` | External PostgreSQL database name | `paperless` | +| `postgresql.external.username` | External PostgreSQL username | `paperless` | +| `postgresql.external.existingSecret` | Existing secret with PostgreSQL credentials | `""` | +| `postgresql.external.passwordKey` | Key in existing secret for PostgreSQL password | `postgresql-password` | +| `redis.external.enabled` | Enable external Redis configuration | `true` | +| `redis.external.host` | External Redis host | `redis.default.svc.cluster.local` | +| `redis.external.port` | External Redis port | `6379` | +| `redis.external.database` | External Redis database number | `0` | + +### Security Configuration + +| Name | Description | Value | +|----------------------------------------|--------------------------------------------------------------------|---------------------| +| `config.secretKey.existingSecret` | Name of existing secret for Django secret key | `""` | +| `config.secretKey.secretKey` | Key in the existing secret for Django secret key | `secret-key` | +| `config.admin.user` | Admin username to create on startup | `""` | +| `config.admin.password` | Admin password (use existingSecret for production) | `""` | +| `config.admin.email` | Admin email address | `root@localhost` | +| `config.admin.existingSecret` | Name of existing secret for admin credentials | `""` | + +### Application Configuration + +| Name | Description | Value | +|----------------------------------------|--------------------------------------------------------------------|---------------------| +| `config.url` | External URL for Paperless-ngx (e.g., https://paperless.domain.com) | `""` | +| `config.allowedHosts` | Comma-separated list of allowed hosts | `*` | +| `config.timeZone` | Application timezone | `UTC` | +| `config.ocr.language` | OCR language (3-letter code) | `eng` | +| `config.ocr.mode` | OCR mode (skip, redo, force) | `skip` | +| `config.consumer.recursive` | Enable recursive consumption directory watching | `false` | +| `config.consumer.subdirsAsTags` | Use subdirectory names as tags | `false` | + +### Persistence Parameters + +| Name | Description | Value | +|----------------------------------------|--------------------------------------------------------------------|---------------------| +| `persistence.data.enabled` | Enable persistence for data directory | `true` | +| `persistence.data.size` | Size of data PVC | `1Gi` | +| `persistence.media.enabled` | Enable persistence for media directory | `true` | +| `persistence.media.size` | Size of media PVC | `10Gi` | +| `persistence.consume.enabled` | Enable persistence for consume directory | `true` | +| `persistence.consume.size` | Size of consume PVC | `5Gi` | +| `persistence.export.enabled` | Enable persistence for export directory | `true` | +| `persistence.export.size` | Size of export PVC | `1Gi` | + +### Service Parameters + +| Name | Description | Value | +|----------------------------|------------------------------------------------------|-------------| +| `service.type` | Kubernetes Service type | `ClusterIP` | +| `service.port` | Service HTTP port | `8000` | + +### Ingress Parameters + +| Name | Description | Value | +|----------------------------|------------------------------------------------------|----------------------| +| `ingress.enabled` | Enable ingress record generation | `false` | +| `ingress.className` | IngressClass name | `""` | +| `ingress.annotations` | Additional annotations for the Ingress resource | See values.yaml | +| `ingress.hosts` | Array of host and path objects | See values.yaml | +| `ingress.tls` | TLS configuration | See values.yaml | + +## Usage Examples + +### Basic Installation + +```bash +helm install paperless-ngx . \ + --set postgresql.external.host=my-postgres.example.com \ + --set postgresql.external.password=secretpassword \ + --set redis.external.host=my-redis.example.com +``` + +### Production Installation with External Secrets + +```yaml +# values-production.yaml +config: + url: "https://paperless.example.com" + allowedHosts: "paperless.example.com" + secretKey: + existingSecret: "paperless-secrets" + secretKey: "django-secret-key" + admin: + user: "admin" + existingSecret: "paperless-admin-secrets" + +postgresql: + external: + host: "postgresql.database.svc.cluster.local" + existingSecret: "paperless-db-secrets" + passwordKey: "password" + +redis: + external: + host: "redis.cache.svc.cluster.local" + +ingress: + enabled: true + className: "nginx" + hosts: + - host: paperless.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: paperless-tls + hosts: + - paperless.example.com +``` + +```bash +helm install paperless-ngx . -f values-production.yaml +``` + +## Security Considerations + +1. **Use external secrets** for production deployments to store sensitive data like database passwords and the Django secret key. +2. **Set a proper PAPERLESS_URL** when exposing the application externally. +3. **Configure ALLOWED_HOSTS** to restrict which hosts can access the application. +4. **Use HTTPS** when exposing the application to the internet. +5. **Container Security**: The container runs as root initially to allow s6-overlay to set up the runtime environment, then drops privileges to UID 1000. This is required for the Paperless-ngx Docker image to function properly. + +## Volumes and Data + +Paperless-ngx uses several directories: + +- **Data directory**: Contains the search index, classification model, and SQLite database (if used) +- **Media directory**: Contains all uploaded documents and thumbnails +- **Consume directory**: Drop documents here for automatic processing +- **Export directory**: Used for document exports + +All directories can be configured with separate PVCs and storage classes. + +## Uninstalling the Chart + +To uninstall/delete the `paperless-ngx` deployment: + +```bash +helm uninstall paperless-ngx +``` + +The command removes all the Kubernetes components associated with the chart and deletes the release. + +## Contributing + +Please feel free to contribute by opening issues or pull requests at: +https://github.com/rtomik/helm-charts + +## License + +This Helm chart is licensed under the MIT License. + +## Links + +- [Paperless-ngx Documentation](https://docs.paperless-ngx.com/) +- [Paperless-ngx GitHub Repository](https://github.com/paperless-ngx/paperless-ngx) +- [Docker Hub](https://hub.docker.com/r/ghcr.io/paperless-ngx/paperless-ngx) \ No newline at end of file diff --git a/charts/paperless-ngx/templates/_helpers.tpl b/charts/paperless-ngx/templates/_helpers.tpl new file mode 100644 index 0000000..feee279 --- /dev/null +++ b/charts/paperless-ngx/templates/_helpers.tpl @@ -0,0 +1,99 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "paperless-ngx.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "paperless-ngx.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- printf "%s" $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "paperless-ngx.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "paperless-ngx.labels" -}} +helm.sh/chart: {{ include "paperless-ngx.chart" . }} +{{ include "paperless-ngx.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "paperless-ngx.selectorLabels" -}} +app.kubernetes.io/name: {{ include "paperless-ngx.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +PostgreSQL host +*/}} +{{- define "paperless-ngx.postgresql.host" -}} +{{- if .Values.postgresql.external.enabled }} +{{- .Values.postgresql.external.host }} +{{- else }} +{{- printf "%s-postgresql" (include "paperless-ngx.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +PostgreSQL port +*/}} +{{- define "paperless-ngx.postgresql.port" -}} +{{- if .Values.postgresql.external.enabled }} +{{- .Values.postgresql.external.port | toString }} +{{- else }} +{{- "5432" }} +{{- end }} +{{- end }} + +{{/* +Redis host +*/}} +{{- define "paperless-ngx.redis.host" -}} +{{- if .Values.redis.external.enabled }} +{{- .Values.redis.external.host }} +{{- else }} +{{- printf "%s-redis" (include "paperless-ngx.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Redis port +*/}} +{{- define "paperless-ngx.redis.port" -}} +{{- if .Values.redis.external.enabled }} +{{- .Values.redis.external.port | toString }} +{{- else }} +{{- "6379" }} +{{- end }} +{{- end }} + +{{/* +Redis URL +*/}} +{{- define "paperless-ngx.redis.url" -}} +{{- $host := include "paperless-ngx.redis.host" . }} +{{- $port := include "paperless-ngx.redis.port" . }} +{{- $database := .Values.redis.external.database | toString }} +{{- printf "redis://%s:%s/%s" $host $port $database }} +{{- end }} \ No newline at end of file diff --git a/charts/paperless-ngx/templates/configmap.yaml b/charts/paperless-ngx/templates/configmap.yaml new file mode 100644 index 0000000..5f8a6c0 --- /dev/null +++ b/charts/paperless-ngx/templates/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "paperless-ngx.fullname" . }}-configmap + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} +data: + # Additional configuration files can be added here if needed + # Most Paperless-ngx configuration is handled via environment variables + README.txt: | + This ConfigMap can be used to store additional configuration files + for Paperless-ngx if needed. The main configuration is handled via + environment variables in the deployment. \ No newline at end of file diff --git a/charts/paperless-ngx/templates/deployment.yaml b/charts/paperless-ngx/templates/deployment.yaml new file mode 100644 index 0000000..606fcbb --- /dev/null +++ b/charts/paperless-ngx/templates/deployment.yaml @@ -0,0 +1,366 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "paperless-ngx.fullname" . }} + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + selector: + matchLabels: + {{- include "paperless-ngx.selectorLabels" . | nindent 6 }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + template: + metadata: + labels: + {{- include "paperless-ngx.selectorLabels" . | nindent 8 }} + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- if .Values.probes.liveness.enabled }} + livenessProbe: + httpGet: + path: {{ .Values.probes.liveness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + successThreshold: {{ .Values.probes.liveness.successThreshold }} + {{- end }} + {{- if .Values.probes.readiness.enabled }} + readinessProbe: + httpGet: + path: {{ .Values.probes.readiness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + successThreshold: {{ .Values.probes.readiness.successThreshold }} + {{- end }} + env: + # Required services + - name: PAPERLESS_REDIS + value: {{ include "paperless-ngx.redis.url" . | quote }} + - name: PAPERLESS_DBHOST + value: {{ include "paperless-ngx.postgresql.host" . | quote }} + - name: PAPERLESS_DBPORT + value: {{ include "paperless-ngx.postgresql.port" . | quote }} + - name: PAPERLESS_DBNAME + value: {{ .Values.postgresql.external.database | quote }} + - name: PAPERLESS_DBUSER + value: {{ .Values.postgresql.external.username | quote }} + + # Database password from secret + - name: PAPERLESS_DBPASS + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.external.existingSecret | default (printf "%s-secrets" (include "paperless-ngx.fullname" .)) }} + key: {{ .Values.postgresql.external.passwordKey | default "postgresql-password" }} + + # Security + - name: PAPERLESS_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.config.secretKey.existingSecret | default (printf "%s-secrets" (include "paperless-ngx.fullname" .)) }} + key: {{ .Values.config.secretKey.secretKey | default "secret-key" }} + + # Basic configuration + {{- if .Values.config.url }} + - name: PAPERLESS_URL + value: {{ .Values.config.url | quote }} + {{- end }} + - name: PAPERLESS_ALLOWED_HOSTS + value: {{ .Values.config.allowedHosts | quote }} + {{- if .Values.config.csrfTrustedOrigins }} + - name: PAPERLESS_CSRF_TRUSTED_ORIGINS + value: {{ .Values.config.csrfTrustedOrigins | quote }} + {{- end }} + - name: PAPERLESS_CORS_ALLOWED_HOSTS + value: {{ .Values.config.corsAllowedHosts | quote }} + {{- if .Values.config.forceScriptName }} + - name: PAPERLESS_FORCE_SCRIPT_NAME + value: {{ .Values.config.forceScriptName | quote }} + {{- end }} + + # Paths + - name: PAPERLESS_DATA_DIR + value: "/usr/src/paperless/data" + - name: PAPERLESS_MEDIA_ROOT + value: "/usr/src/paperless/media" + - name: PAPERLESS_CONSUMPTION_DIR + value: "/usr/src/paperless/consume" + + # Docker/User settings (s6-overlay compatible) + - name: USERMAP_UID + value: "1000" + - name: USERMAP_GID + value: "1000" + + # OCR settings + - name: PAPERLESS_OCR_LANGUAGE + value: {{ .Values.config.ocr.language | quote }} + - name: PAPERLESS_OCR_MODE + value: {{ .Values.config.ocr.mode | quote }} + - name: PAPERLESS_OCR_SKIP_ARCHIVE_FILE + value: {{ .Values.config.ocr.skipArchiveFile | quote }} + - name: PAPERLESS_OCR_CLEAN + value: {{ .Values.config.ocr.clean | quote }} + - name: PAPERLESS_OCR_DESKEW + value: {{ .Values.config.ocr.deskew | quote }} + - name: PAPERLESS_OCR_ROTATE_PAGES + value: {{ .Values.config.ocr.rotatePages | quote }} + - name: PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD + value: {{ .Values.config.ocr.rotatePagesThreshold | quote }} + - name: PAPERLESS_OCR_OUTPUT_TYPE + value: {{ .Values.config.ocr.outputType | quote }} + {{- if ne (.Values.config.ocr.pages | int) 0 }} + - name: PAPERLESS_OCR_PAGES + value: {{ .Values.config.ocr.pages | quote }} + {{- end }} + {{- if ne (.Values.config.ocr.imageDpi | int) 0 }} + - name: PAPERLESS_OCR_IMAGE_DPI + value: {{ .Values.config.ocr.imageDpi | quote }} + {{- end }} + {{- if ne (.Values.config.ocr.maxImagePixels | int) 0 }} + - name: PAPERLESS_OCR_MAX_IMAGE_PIXELS + value: {{ .Values.config.ocr.maxImagePixels | quote }} + {{- end }} + {{- if ne .Values.config.ocr.userArgs "{}" }} + - name: PAPERLESS_OCR_USER_ARGS + value: {{ .Values.config.ocr.userArgs | quote }} + {{- end }} + + # Time and locale + - name: PAPERLESS_TIME_ZONE + value: {{ .Values.config.timeZone | quote }} + + # Consumer settings + - name: PAPERLESS_CONSUMER_RECURSIVE + value: {{ .Values.config.consumer.recursive | quote }} + - name: PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS + value: {{ .Values.config.consumer.subdirsAsTags | quote }} + - name: PAPERLESS_CONSUMER_DELETE_DUPLICATES + value: {{ .Values.config.consumer.deleteDocumentDuplicates | quote }} + - name: PAPERLESS_CONSUMER_IGNORE_PATTERNS + value: {{ .Values.config.consumer.ignorePatterns | quote }} + - name: PAPERLESS_CONSUMER_BARCODE_SCANNER + value: {{ .Values.config.consumer.barcodeScanner | quote }} + + # Barcode settings + {{- if .Values.config.consumer.barcodes.enabled }} + - name: PAPERLESS_CONSUMER_ENABLE_BARCODES + value: "true" + - name: PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT + value: {{ .Values.config.consumer.barcodes.tiffSupport | quote }} + - name: PAPERLESS_CONSUMER_BARCODE_STRING + value: {{ .Values.config.consumer.barcodes.string | quote }} + - name: PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES + value: {{ .Values.config.consumer.barcodes.retainSplitPages | quote }} + {{- if ne (.Values.config.consumer.barcodes.upscale | float64) 0.0 }} + - name: PAPERLESS_CONSUMER_BARCODE_UPSCALE + value: {{ .Values.config.consumer.barcodes.upscale | quote }} + {{- end }} + - name: PAPERLESS_CONSUMER_BARCODE_DPI + value: {{ .Values.config.consumer.barcodes.dpi | quote }} + {{- if ne (.Values.config.consumer.barcodes.maxPages | int) 0 }} + - name: PAPERLESS_CONSUMER_BARCODE_MAX_PAGES + value: {{ .Values.config.consumer.barcodes.maxPages | quote }} + {{- end }} + {{- end }} + + # ASN barcode settings + {{- if .Values.config.consumer.barcodes.asnEnabled }} + - name: PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE + value: "true" + - name: PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX + value: {{ .Values.config.consumer.barcodes.asnPrefix | quote }} + {{- end }} + + # Tag barcode settings + {{- if .Values.config.consumer.barcodes.tagEnabled }} + - name: PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE + value: "true" + - name: PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING + value: {{ .Values.config.consumer.barcodes.tagMapping | quote }} + {{- end }} + + # Tika settings + {{- if .Values.config.tika.enabled }} + - name: PAPERLESS_TIKA_ENABLED + value: "true" + - name: PAPERLESS_TIKA_ENDPOINT + value: {{ .Values.config.tika.endpoint | quote }} + - name: PAPERLESS_TIKA_GOTENBERG_ENDPOINT + value: {{ .Values.config.tika.gotenbergEndpoint | quote }} + {{- end }} + + # Admin user + {{- if .Values.config.admin.user }} + - name: PAPERLESS_ADMIN_USER + value: {{ .Values.config.admin.user | quote }} + - name: PAPERLESS_ADMIN_MAIL + value: {{ .Values.config.admin.email | quote }} + - name: PAPERLESS_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.config.admin.existingSecret | default (printf "%s-secrets" (include "paperless-ngx.fullname" .)) }} + key: {{ .Values.config.admin.passwordKey | default "admin-password" }} + {{- end }} + + # Email settings + {{- if .Values.config.email.host }} + - name: PAPERLESS_EMAIL_HOST + value: {{ .Values.config.email.host | quote }} + - name: PAPERLESS_EMAIL_PORT + value: {{ .Values.config.email.port | quote }} + {{- if .Values.config.email.user }} + - name: PAPERLESS_EMAIL_HOST_USER + value: {{ .Values.config.email.user | quote }} + - name: PAPERLESS_EMAIL_HOST_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.config.email.existingSecret | default (printf "%s-secrets" (include "paperless-ngx.fullname" .)) }} + key: {{ .Values.config.email.passwordKey | default "email-password" }} + {{- end }} + {{- if .Values.config.email.from }} + - name: PAPERLESS_EMAIL_FROM + value: {{ .Values.config.email.from | quote }} + {{- end }} + - name: PAPERLESS_EMAIL_USE_TLS + value: {{ .Values.config.email.useTls | quote }} + - name: PAPERLESS_EMAIL_USE_SSL + value: {{ .Values.config.email.useSsl | quote }} + {{- end }} + + # Task processing + - name: PAPERLESS_TASK_WORKERS + value: {{ .Values.config.taskWorkers | quote }} + - name: PAPERLESS_THREADS_PER_WORKER + value: {{ .Values.config.threadsPerWorker | quote }} + - name: PAPERLESS_WORKER_TIMEOUT + value: {{ .Values.config.workerTimeout | quote }} + + # Advanced settings + - name: PAPERLESS_ENABLE_NLTK + value: {{ .Values.config.enableNltk | quote }} + {{- if .Values.config.filenameFormat }} + - name: PAPERLESS_FILENAME_FORMAT + value: {{ .Values.config.filenameFormat | quote }} + - name: PAPERLESS_FILENAME_FORMAT_REMOVE_NONE + value: {{ .Values.config.filenameFormatRemoveNone | quote }} + {{- end }} + {{- if ne (.Values.config.convertMemoryLimit | int) 0 }} + - name: PAPERLESS_CONVERT_MEMORY_LIMIT + value: {{ .Values.config.convertMemoryLimit | quote }} + {{- end }} + {{- if .Values.config.convertTmpDir }} + - name: PAPERLESS_CONVERT_TMPDIR + value: {{ .Values.config.convertTmpDir | quote }} + {{- end }} + {{- if ne (.Values.config.maxImagePixels | int) 0 }} + - name: PAPERLESS_MAX_IMAGE_PIXELS + value: {{ .Values.config.maxImagePixels | quote }} + {{- end }} + + # Custom environment variables + {{- range .Values.env }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnvFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /usr/src/paperless/data + - name: media + mountPath: /usr/src/paperless/media + - name: export + mountPath: /usr/src/paperless/export + - name: consume + mountPath: /usr/src/paperless/consume + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + {{- if .Values.persistence.data.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ include "paperless-ngx.fullname" . }}-data + {{- else }} + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.persistence.media.enabled }} + - name: media + persistentVolumeClaim: + claimName: {{ include "paperless-ngx.fullname" . }}-media + {{- else }} + - name: media + emptyDir: {} + {{- end }} + {{- if .Values.persistence.export.enabled }} + - name: export + persistentVolumeClaim: + claimName: {{ include "paperless-ngx.fullname" . }}-export + {{- else }} + - name: export + emptyDir: {} + {{- end }} + {{- if .Values.persistence.consume.enabled }} + - name: consume + persistentVolumeClaim: + claimName: {{ include "paperless-ngx.fullname" . }}-consume + {{- else }} + - name: consume + emptyDir: {} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/charts/paperless-ngx/templates/ingress.yaml b/charts/paperless-ngx/templates/ingress.yaml new file mode 100644 index 0000000..a7d40f9 --- /dev/null +++ b/charts/paperless-ngx/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "paperless-ngx.fullname" . }} + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + {{- if .secretName }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "paperless-ngx.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/paperless-ngx/templates/pvc.yaml b/charts/paperless-ngx/templates/pvc.yaml new file mode 100644 index 0000000..528c4b1 --- /dev/null +++ b/charts/paperless-ngx/templates/pvc.yaml @@ -0,0 +1,90 @@ +{{- if .Values.persistence.data.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "paperless-ngx.fullname" . }}-data + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} + {{- with .Values.persistence.data.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.data.accessMode | quote }} + {{- if .Values.persistence.data.storageClass }} + storageClassName: {{ .Values.persistence.data.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.data.size | quote }} +--- +{{- end }} + +{{- if .Values.persistence.media.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "paperless-ngx.fullname" . }}-media + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} + {{- with .Values.persistence.media.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.media.accessMode | quote }} + {{- if .Values.persistence.media.storageClass }} + storageClassName: {{ .Values.persistence.media.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.media.size | quote }} +--- +{{- end }} + +{{- if .Values.persistence.export.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "paperless-ngx.fullname" . }}-export + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} + {{- with .Values.persistence.export.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.export.accessMode | quote }} + {{- if .Values.persistence.export.storageClass }} + storageClassName: {{ .Values.persistence.export.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.export.size | quote }} +--- +{{- end }} + +{{- if .Values.persistence.consume.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "paperless-ngx.fullname" . }}-consume + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} + {{- with .Values.persistence.consume.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.consume.accessMode | quote }} + {{- if .Values.persistence.consume.storageClass }} + storageClassName: {{ .Values.persistence.consume.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.consume.size | quote }} +{{- end }} \ No newline at end of file diff --git a/charts/paperless-ngx/templates/secret.yaml b/charts/paperless-ngx/templates/secret.yaml new file mode 100644 index 0000000..545df8e --- /dev/null +++ b/charts/paperless-ngx/templates/secret.yaml @@ -0,0 +1,38 @@ +{{- $needsSecret := false -}} +{{- if not .Values.config.secretKey.existingSecret -}} + {{- $needsSecret = true -}} +{{- end -}} +{{- if not .Values.postgresql.external.existingSecret -}} + {{- $needsSecret = true -}} +{{- end -}} +{{- if and .Values.config.admin.user (not .Values.config.admin.existingSecret) -}} + {{- $needsSecret = true -}} +{{- end -}} +{{- if and .Values.config.email.host .Values.config.email.user (not .Values.config.email.existingSecret) -}} + {{- $needsSecret = true -}} +{{- end -}} + +{{- if $needsSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "paperless-ngx.fullname" . }}-secrets + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} +type: Opaque +data: + {{- if not .Values.config.secretKey.existingSecret }} + {{ .Values.config.secretKey.secretKey | default "secret-key" }}: {{ .Values.config.secretKey.value | default "change-me-paperless-secret-key-at-least-32-characters-long" | b64enc }} + {{- end }} + {{- if not .Values.postgresql.external.existingSecret }} + {{ .Values.postgresql.external.passwordKey | default "postgresql-password" }}: {{ .Values.postgresql.external.password | default "paperless" | b64enc }} + {{- end }} + {{- if and .Values.config.admin.user (not .Values.config.admin.existingSecret) }} + {{ .Values.config.admin.userKey | default "admin-user" }}: {{ .Values.config.admin.user | b64enc }} + {{ .Values.config.admin.passwordKey | default "admin-password" }}: {{ .Values.config.admin.password | default "changeme" | b64enc }} + {{- end }} + {{- if and .Values.config.email.host .Values.config.email.user (not .Values.config.email.existingSecret) }} + {{ .Values.config.email.userKey | default "email-user" }}: {{ .Values.config.email.user | b64enc }} + {{ .Values.config.email.passwordKey | default "email-password" }}: {{ .Values.config.email.password | default "" | b64enc }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/paperless-ngx/templates/service.yaml b/charts/paperless-ngx/templates/service.yaml new file mode 100644 index 0000000..f93aa00 --- /dev/null +++ b/charts/paperless-ngx/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "paperless-ngx.fullname" . }} + labels: + {{- include "paperless-ngx.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "paperless-ngx.selectorLabels" . | nindent 4 }} \ No newline at end of file diff --git a/charts/paperless-ngx/values.yaml b/charts/paperless-ngx/values.yaml new file mode 100644 index 0000000..774bb5d --- /dev/null +++ b/charts/paperless-ngx/values.yaml @@ -0,0 +1,289 @@ +## Global settings +nameOverride: "" +fullnameOverride: "" + +## Image settings +image: + repository: ghcr.io/paperless-ngx/paperless-ngx + tag: "2.18.4" + pullPolicy: IfNotPresent + +## Deployment settings +replicaCount: 1 +revisionHistoryLimit: 3 + +# Pod security settings +# Note: Paperless-ngx uses s6-overlay which requires root access during initialization +# The container will drop privileges after setup +podSecurityContext: + runAsNonRoot: false + runAsUser: 0 + fsGroup: 1000 + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + +## Pod scheduling +nodeSelector: {} +tolerations: [] +affinity: {} + +## Service settings +service: + type: ClusterIP + port: 8000 + +## Ingress settings +ingress: + enabled: false + className: "" + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + hosts: + - host: paperless.domain.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: + - paperless.domain.com + # Optional: specify the name of an existing TLS secret + # secretName: "existing-tls-secret" + +## Persistence settings +persistence: + # Paperless data directory (search index, classification model, etc.) + data: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 1Gi + annotations: {} + # Paperless media directory (documents and thumbnails) + media: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + annotations: {} + # Export directory (for exporting documents) + export: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 1Gi + annotations: {} + # Consume directory (for importing documents) + consume: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 5Gi + annotations: {} + +# Extra volume mounts +extraVolumeMounts: [] + +# Extra volumes +extraVolumes: [] + +## Resource limits and requests +# resources: +# limits: +# cpu: 1000m +# memory: 1Gi +# requests: +# cpu: 200m +# memory: 512Mi + +## Application health checks +probes: + liveness: + enabled: true + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + successThreshold: 1 + path: / + readiness: + enabled: true + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + successThreshold: 1 + path: / + +## Autoscaling configuration +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + +## External Dependencies Configuration +## These should point to external PostgreSQL and Redis services + +# External PostgreSQL database configuration +postgresql: + # External PostgreSQL connection details + external: + enabled: true + host: "postgresql.default.svc.cluster.local" + port: 5432 + database: "paperless" + username: "paperless" + # Use existingSecret for credentials + existingSecret: "" + passwordKey: "postgresql-password" + # Or set password directly (not recommended for production) + password: "" + +# External Redis configuration +redis: + external: + enabled: true + host: "redis.default.svc.cluster.local" + port: 6379 + database: 0 + # Use existingSecret for credentials if Redis has auth + existingSecret: "" + passwordKey: "redis-password" + # Or set password directly (leave empty if no auth) + password: "" + +## Paperless-ngx Configuration +config: + # Basic server configuration + url: "" # Set to your external URL, e.g., https://paperless.domain.com + allowedHosts: "*" # Comma-separated list of allowed hosts + csrfTrustedOrigins: "" # Comma-separated list of trusted origins + corsAllowedHosts: "http://localhost:8000" + forceScriptName: "" # For hosting under subpath, e.g., /paperless + + # Security settings + secretKey: + # Use existingSecret for production + existingSecret: "" + secretKey: "secret-key" + # Or set directly (not recommended for production) + value: "" + + # OCR Configuration + ocr: + language: "eng" # OCR language (3-letter code) + mode: "skip" # skip, redo, or force + skipArchiveFile: "never" # never, with_text, always + clean: "clean" # clean, clean-final, none + deskew: true + rotatePages: true + rotatePagesThreshold: 12 + outputType: "pdfa" + pages: 0 # 0 = all pages + imageDpi: 0 # 0 = auto + maxImagePixels: 0 # 0 = use Pillow default + userArgs: "{}" # JSON string of additional OCRmyPDF arguments + + # Time and locale settings + timeZone: "UTC" + + # Consumer settings + consumer: + recursive: false + subdirsAsTags: false + deleteDocumentDuplicates: false + ignorePatterns: '[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]' + barcodeScanner: "PYZBAR" + + # Barcode processing + barcodes: + enabled: false + tiffSupport: false + string: "PATCHT" + retainSplitPages: false + upscale: 0.0 + dpi: 300 + maxPages: 0 + + # ASN barcode settings + asnEnabled: false + asnPrefix: "ASN" + + # Tag barcode settings + tagEnabled: false + tagMapping: '{"TAG:(.*)": "\\g<1>"}' + + # Optional Tika settings (for Office documents) + tika: + enabled: false + endpoint: "http://tika:9998" + gotenbergEndpoint: "http://gotenberg:3000" + + # Admin user creation (optional) + admin: + user: "" # Set to create admin user on startup + password: "" # Required if admin.user is set + email: "root@localhost" + + # Use existingSecret for credentials + existingSecret: "" + userKey: "admin-user" + passwordKey: "admin-password" + + # Email configuration (optional) + email: + host: "" + port: 25 + user: "" + password: "" + from: "" + useTls: false + useSsl: false + + # Use existingSecret for credentials + existingSecret: "" + userKey: "email-user" + passwordKey: "email-password" + + # Logging + logging: + dir: "" # Uses PAPERLESS_DATA_DIR/log/ if empty + + # Task processing + taskWorkers: 1 + threadsPerWorker: 1 + workerTimeout: 1800 + + # Advanced settings + filenameFormat: "" + filenameFormatRemoveNone: false + enableNltk: true + convertMemoryLimit: 0 + convertTmpDir: "" + maxImagePixels: 0 + +# Environment variables +env: [] + # Example additional env vars: + # - name: PAPERLESS_ENABLE_HTTP_REMOTE_USER + # value: "false" + +# Extra environment variables from secrets +extraEnvFrom: [] + # - secretRef: + # name: paperless-extra-secrets + +# Extra environment variables (for advanced use cases) +extraEnv: [] \ No newline at end of file