joplin-server helmchart v0.0.1

This commit is contained in:
Richard Tomik
2025-08-24 21:47:31 +02:00
parent 7cb71b046c
commit e809d6067d
10 changed files with 1378 additions and 57 deletions

View File

@ -0,0 +1,17 @@
apiVersion: v2
name: joplin-server
description: Joplin Server helm chart for Kubernetes - Note-taking and synchronization server
type: application
version: 0.0.1
appVersion: "3.4.2"
maintainers:
- name: Richard Tomik
email: no@m.com
keywords:
- notes
- synchronization
- joplin
- productivity
home: https://github.com/rtomik/helm-charts
sources:
- https://github.com/laurent22/joplin

View File

@ -0,0 +1,106 @@
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 "joplin-server.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 "joplin-server.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "joplin-server.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 "joplin-server.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:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
2. Joplin Server is configured with:
- Database: PostgreSQL ({{ .Values.postgresql.external.host }}:{{ .Values.postgresql.external.port }}/{{ .Values.postgresql.external.database }})
- Storage: {{ .Values.joplin.storage.driver }}
{{- if eq .Values.joplin.storage.driver "filesystem" }}
- Data path: {{ .Values.joplin.storage.filesystemPath }}
{{- end }}
- User registration: {{ if .Values.joplin.server.enableUserRegistration }}enabled{{ else }}disabled{{ end }}
- Sharing: {{ if .Values.joplin.server.enableSharing }}enabled{{ else }}disabled{{ end }}
{{- if and (eq .Values.joplin.storage.driver "filesystem") .Values.persistence.enabled }}
3. Data is persisted using PVC: {{ include "joplin-server.fullname" . }}-data
{{- else if eq .Values.joplin.storage.driver "filesystem" }}
3. WARNING: No persistence enabled. Data will be lost when pods are restarted.
{{- else }}
3. Using {{ .Values.joplin.storage.driver }} storage - no local persistence needed.
{{- end }}
{{- if .Values.transcribe.enabled }}
4. AI Transcription service is enabled:
- Transcribe service accessible internally at: {{ include "joplin-server.fullname" . }}-transcribe:{{ .Values.transcribe.service.port }}
{{- if .Values.transcribe.persistence.enabled }}
- Transcribe images stored in PVC: {{ include "joplin-server.fullname" . }}-transcribe-images
{{- end }}
{{- end }}
{{- if .Values.joplin.email.enabled }}
5. Email notifications configured:
- SMTP host: {{ .Values.joplin.email.host }}:{{ .Values.joplin.email.port }}
- From: {{ .Values.joplin.email.fromName }} <{{ .Values.joplin.email.fromEmail }}>
{{- end }}
{{- if not .Values.joplin.admin.email }}
6. IMPORTANT: First-time setup required!
After accessing the application, you'll need to create your first admin user.
Consider setting joplin.admin.email and joplin.admin.password for automated setup.
{{- else }}
6. Admin user configured:
- Email: {{ .Values.joplin.admin.email }}
{{- if .Values.joplin.admin.existingSecret }}
- Password: Retrieved from secret {{ .Values.joplin.admin.existingSecret }}
{{- else }}
- Password: Set in values (consider using existingSecret for production)
{{- end }}
{{- end }}
{{- if not .Values.postgresql.external.enabled }}
7. WARNING: PostgreSQL database not configured!
Please configure postgresql.external settings to connect to your PostgreSQL database.
Joplin Server requires a PostgreSQL database to function.
{{- else if and .Values.postgresql.external.enabled (not .Values.postgresql.external.existingSecret) }}
7. SECURITY NOTE: Database credentials in plain text.
For production use, consider using postgresql.external.existingSecret to store database credentials securely.
{{- end }}
{{- $defaultHost := "joplin.domain.com" }}
{{- $actualHost := "" }}
{{- if .Values.ingress.enabled }}
{{- range .Values.ingress.hosts }}
{{- $actualHost = .host }}
{{- end }}
{{- end }}
{{- if and $actualHost (ne $actualHost $defaultHost) }}
{{- if and .Values.probes.liveness.httpHeaders (len .Values.probes.liveness.httpHeaders) }}
{{- $probeHost := "" }}
{{- range .Values.probes.liveness.httpHeaders }}
{{- if eq .name "Host" }}
{{- $probeHost = .value }}
{{- end }}
{{- end }}
{{- if eq $probeHost $defaultHost }}
8. IMPORTANT: Health check configuration needs updating!
Your ingress host is "{{ $actualHost }}" but health checks are configured for "{{ $defaultHost }}".
Update probes.*.httpHeaders.Host value to match your domain for proper health checks.
{{- end }}
{{- end }}
{{- end }}
For more information about using this Helm chart, please refer to the readme.md file.

View File

@ -0,0 +1,445 @@
# Joplin Server Helm Chart
A Helm chart for deploying Joplin Server on Kubernetes - Note-taking and synchronization server.
## Introduction
This chart deploys [Joplin Server](https://github.com/laurent22/joplin) on a Kubernetes cluster using the Helm package manager. Joplin Server is the synchronization server for Joplin, allowing you to sync your notes across devices.
Source code can be found here:
- https://github.com/rtomik/helm-charts/tree/main/charts/joplin-server
## Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- **External PostgreSQL database** (Required - Joplin Server does not support SQLite in production)
- PV provisioner support in the underlying infrastructure (if persistence is needed for file storage)
## Installing the Chart
To install the chart with the release name `joplin-server`:
```bash
$ helm repo add joplin-chart https://rtomik.github.io/helm-charts
$ helm install joplin-server joplin-chart/joplin-server
```
> **Important**: You must configure PostgreSQL database settings before installation.
## Uninstalling the Chart
To uninstall/delete the `joplin-server` deployment:
```bash
$ helm uninstall joplin-server
```
## Parameters
### 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` | Joplin Server image repository | `joplin/server` |
| `image.tag` | Joplin Server image tag | `latest` |
| `image.pullPolicy` | Joplin Server image pull policy | `IfNotPresent` |
### Deployment parameters
| Name | Description | Value |
|--------------------------------------|-----------------------------------------------|-----------|
| `replicaCount` | Number of Joplin Server replicas | `1` |
| `revisionHistoryLimit` | Number of revisions to retain for rollback | `3` |
| `podSecurityContext.runAsNonRoot` | Run containers as non-root user | `true` |
| `podSecurityContext.runAsUser` | User ID for the container | `1001` |
| `podSecurityContext.fsGroup` | Group ID for the container filesystem | `1001` |
| `containerSecurityContext` | Security context for the container | See values.yaml |
| `nodeSelector` | Node labels for pod assignment | `{}` |
| `tolerations` | Tolerations for pod assignment | `[]` |
| `affinity` | Affinity for pod assignment | `{}` |
### Service parameters
| Name | Description | Value |
|----------------|-----------------------|-------------|
| `service.type` | Kubernetes Service type | `ClusterIP` |
| `service.port` | Service HTTP port | `22300` |
### Ingress parameters
| Name | Description | Value |
|-------------------------|-------------------------------------------|-----------------|
| `ingress.enabled` | Enable ingress record generation | `false` |
| `ingress.className` | IngressClass name | `""` |
| `ingress.annotations` | Additional annotations for the Ingress | See values.yaml |
| `ingress.hosts` | Array of host and path objects | See values.yaml |
| `ingress.tls` | TLS configuration | See values.yaml |
### Environment variables
| Name | Description | Value |
|---------------------------|-----------------------------------------------|--------------------------|
| `env.APP_PORT` | Application port | `22300` |
| `env.APP_BASE_URL` | Base URL for the application | `http://localhost:22300` |
| `env.DB_CLIENT` | Database client (always pg for PostgreSQL) | `pg` |
### PostgreSQL configuration (Required)
| Name | Description | Value |
|----------------------------------------|-----------------------------------------------|-----------|
| `postgresql.external.enabled` | Use external PostgreSQL database (required) | `true` |
| `postgresql.external.host` | PostgreSQL host | `""` |
| `postgresql.external.port` | PostgreSQL port | `5432` |
| `postgresql.external.database` | PostgreSQL database name | `joplin` |
| `postgresql.external.user` | PostgreSQL username | `joplin` |
| `postgresql.external.password` | PostgreSQL password | `""` |
| `postgresql.external.existingSecret` | Name of existing secret with PostgreSQL credentials | `""` |
| `postgresql.external.userKey` | Key in the secret for username | `username` |
| `postgresql.external.passwordKey` | Key in the secret for password | `password` |
| `postgresql.external.hostKey` | Key in the secret for host | `host` |
| `postgresql.external.portKey` | Key in the secret for port | `port` |
| `postgresql.external.databaseKey` | Key in the secret for database name | `database` |
### Joplin Server Configuration
#### Admin Settings
| Name | Description | Value |
|----------------------------------------|-----------------------------------------------|-----------|
| `joplin.admin.email` | First admin user email | `""` |
| `joplin.admin.password` | First admin user password | `""` |
| `joplin.admin.existingSecret` | Name of existing secret with admin credentials | `""` |
| `joplin.admin.emailKey` | Key in the secret for admin email | `admin-email` |
| `joplin.admin.passwordKey` | Key in the secret for admin password | `admin-password` |
#### Server Settings
| Name | Description | Value |
|-------------------------------------------|-----------------------------------------------|-----------|
| `joplin.server.maxRequestBodySize` | Maximum request body size | `200mb` |
| `joplin.server.sessionTimeout` | Session timeout in seconds | `86400` |
| `joplin.server.enableUserRegistration` | Enable/disable user registration | `false` |
| `joplin.server.enableSharing` | Enable/disable sharing | `true` |
| `joplin.server.enablePublicNotes` | Enable/disable public notes | `true` |
#### Storage Settings
| Name | Description | Value |
|-------------------------------------------|-----------------------------------------------|--------------|
| `joplin.storage.driver` | Storage driver (filesystem, s3, azure) | `filesystem` |
| `joplin.storage.filesystemPath` | Path for filesystem storage | `/var/lib/joplin` |
##### S3 Storage (Optional)
| Name | Description | Value |
|---------------------------------------------|---------------------------------------------|-----------|
| `joplin.storage.s3.bucket` | S3 bucket name | `""` |
| `joplin.storage.s3.region` | S3 region | `""` |
| `joplin.storage.s3.accessKeyId` | S3 access key ID | `""` |
| `joplin.storage.s3.secretAccessKey` | S3 secret access key | `""` |
| `joplin.storage.s3.endpoint` | S3 endpoint (for S3-compatible services) | `""` |
| `joplin.storage.s3.existingSecret` | Name of existing secret with S3 credentials | `""` |
| `joplin.storage.s3.accessKeyIdKey` | Key in the secret for access key ID | `access-key-id` |
| `joplin.storage.s3.secretAccessKeyKey` | Key in the secret for secret access key | `secret-access-key` |
#### Email Settings (Optional)
| Name | Description | Value |
|----------------------------------------|-----------------------------------------------|-----------|
| `joplin.email.enabled` | Enable email notifications | `false` |
| `joplin.email.host` | SMTP host | `""` |
| `joplin.email.port` | SMTP port | `587` |
| `joplin.email.username` | SMTP username | `""` |
| `joplin.email.password` | SMTP password | `""` |
| `joplin.email.fromEmail` | From email address | `""` |
| `joplin.email.fromName` | From name | `Joplin Server` |
| `joplin.email.secure` | Use TLS/SSL | `true` |
| `joplin.email.existingSecret` | Name of existing secret with email credentials | `""` |
| `joplin.email.usernameKey` | Key in the secret for SMTP username | `email-username` |
| `joplin.email.passwordKey` | Key in the secret for SMTP password | `email-password` |
#### Logging Settings
| Name | Description | Value |
|--------------------------------|--------------------------------------|-----------|
| `joplin.logging.level` | Log level (error, warn, info, debug) | `info` |
| `joplin.logging.target` | Log target (console, file) | `console` |
### Persistence settings (for filesystem storage)
| Name | Description | Value |
|-------------------------------|----------------------------------|-----------------|
| `persistence.enabled` | Enable persistence using PVC | `true` |
| `persistence.storageClass` | PVC Storage Class | `""` |
| `persistence.accessMode` | PVC Access Mode | `ReadWriteOnce` |
| `persistence.size` | PVC Size | `10Gi` |
| `persistence.annotations` | Annotations for PVC | `{}` |
### Transcribe Service (Optional AI Transcription)
| Name | Description | Value |
|-------------------------------------------|-----------------------------------------------|--------------|
| `transcribe.enabled` | Enable transcribe service | `false` |
| `transcribe.image.repository` | Transcribe image repository | `joplin/transcribe` |
| `transcribe.image.tag` | Transcribe image tag | `latest` |
| `transcribe.image.pullPolicy` | Transcribe image pull policy | `IfNotPresent` |
| `transcribe.api.key` | Shared secret between Joplin and Transcribe | `""` |
| `transcribe.api.existingSecret` | Name of existing secret with transcribe API key | `""` |
| `transcribe.api.keyName` | Key in the secret for transcribe API key | `transcribe-api-key` |
| `transcribe.service.type` | Transcribe service type | `ClusterIP` |
| `transcribe.service.port` | Transcribe service port | `4567` |
| `transcribe.htr.imagesFolder` | HTR images folder path | `/app/images` |
#### Transcribe Database (Separate from main database)
| Name | Description | Value |
|---------------------------------------------|---------------------------------------------|-------------|
| `transcribe.database.host` | Transcribe database host | `""` |
| `transcribe.database.port` | Transcribe database port | `5432` |
| `transcribe.database.database` | Transcribe database name | `transcribe` |
| `transcribe.database.user` | Transcribe database username | `transcribe` |
| `transcribe.database.password` | Transcribe database password | `""` |
| `transcribe.database.existingSecret` | Name of existing secret with transcribe DB credentials | `""` |
| `transcribe.database.userKey` | Key in the secret for username | `username` |
| `transcribe.database.passwordKey` | Key in the secret for password | `password` |
### Resource Configuration
| Name | Description | Value |
|-------------|--------------------------------------|-------|
| `resources` | Resource limits and requests | `{}` |
### Health Checks
| Name | Description | Value |
|-------------------------------------------|------------------------------------------|-------|
| `probes.liveness.enabled` | Enable liveness probe | `true` |
| `probes.liveness.initialDelaySeconds` | Initial delay for liveness probe | `60` |
| `probes.liveness.periodSeconds` | Period for liveness probe | `30` |
| `probes.liveness.timeoutSeconds` | Timeout for liveness probe | `10` |
| `probes.liveness.failureThreshold` | Failure threshold for liveness probe | `3` |
| `probes.liveness.successThreshold` | Success threshold for liveness probe | `1` |
| `probes.liveness.path` | Path for liveness probe | `/api/ping` |
| `probes.liveness.httpHeaders` | HTTP headers for liveness probe | `[{"name": "Host", "value": "joplin.domain.com"}]` |
| `probes.readiness.enabled` | Enable readiness probe | `true` |
| `probes.readiness.initialDelaySeconds` | Initial delay for readiness probe | `30` |
| `probes.readiness.periodSeconds` | Period for readiness probe | `10` |
| `probes.readiness.timeoutSeconds` | Timeout for readiness probe | `5` |
| `probes.readiness.failureThreshold` | Failure threshold for readiness probe | `3` |
| `probes.readiness.successThreshold` | Success threshold for readiness probe | `1` |
| `probes.readiness.path` | Path for readiness probe | `/api/ping` |
| `probes.readiness.httpHeaders` | HTTP headers for readiness probe | `[{"name": "Host", "value": "joplin.domain.com"}]` |
### Autoscaling
| Name | Description | Value |
|---------------------------------------------|------------------------------------------|---------|
| `autoscaling.enabled` | Enable horizontal pod autoscaling | `false` |
| `autoscaling.minReplicas` | Minimum number of replicas | `1` |
| `autoscaling.maxReplicas` | Maximum number of replicas | `3` |
| `autoscaling.targetCPUUtilizationPercentage`| Target CPU utilization percentage | `80` |
| `autoscaling.targetMemoryUtilizationPercentage`| Target memory utilization percentage | `80` |
### Security Settings
| Name | Description | Value |
|-----------------------------------|--------------------------------------|---------|
| `security.httpsRedirect` | Enable/disable HTTPS redirect | `false` |
| `security.tls.enabled` | Enable custom TLS certificate | `false` |
| `security.tls.existingSecret` | Name of existing secret with TLS cert| `""` |
| `security.tls.certificateKey` | Key in the secret for TLS certificate| `tls.crt` |
| `security.tls.privateKeyKey` | Key in the secret for TLS private key| `tls.key` |
## Configuration Examples
### Basic Installation with PostgreSQL
```yaml
postgresql:
external:
enabled: true
host: "postgresql.example.com"
port: 5432
database: "joplin"
user: "joplin"
password: "secure-password"
ingress:
enabled: true
hosts:
- host: joplin.example.com
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- joplin.example.com
secretName: joplin-tls
env:
APP_BASE_URL: "https://joplin.example.com"
# IMPORTANT: Update health check host headers to match your domain
probes:
liveness:
httpHeaders:
- name: Host
value: joplin.example.com
readiness:
httpHeaders:
- name: Host
value: joplin.example.com
joplin:
admin:
email: "admin@example.com"
password: "admin-password"
server:
enableUserRegistration: true
```
### Using Kubernetes Secrets
```yaml
postgresql:
external:
enabled: true
existingSecret: "joplin-postgresql-secret"
hostKey: "host"
portKey: "port"
databaseKey: "database"
userKey: "username"
passwordKey: "password"
joplin:
admin:
existingSecret: "joplin-admin-secret"
emailKey: "email"
passwordKey: "password"
```
### S3 Storage Configuration
```yaml
joplin:
storage:
driver: "s3"
s3:
bucket: "joplin-notes"
region: "us-east-1"
existingSecret: "joplin-s3-secret"
accessKeyIdKey: "access-key-id"
secretAccessKeyKey: "secret-access-key"
# No persistence needed when using S3
persistence:
enabled: false
```
### Email Notifications Setup
```yaml
joplin:
email:
enabled: true
host: "smtp.example.com"
port: 587
fromEmail: "joplin@example.com"
fromName: "Joplin Server"
secure: true
existingSecret: "joplin-email-secret"
usernameKey: "username"
passwordKey: "password"
```
### Transcribe Service (AI Features)
```yaml
transcribe:
enabled: true
api:
existingSecret: "joplin-transcribe-secret"
keyName: "api-key"
database:
host: "postgresql.example.com"
port: 5432
database: "transcribe"
user: "transcribe"
existingSecret: "transcribe-db-secret"
userKey: "username"
passwordKey: "password"
persistence:
enabled: true
size: 5Gi
```
## First-time Setup
1. **Configure PostgreSQL**: Ensure your PostgreSQL database is accessible and credentials are configured
2. **Admin User**: Set admin email/password or access the web interface to create the first admin user
3. **User Registration**: Configure whether users can self-register or admin approval is required
4. **Storage**: Choose between filesystem (requires persistence) or cloud storage (S3/Azure)
## Security Considerations
For production deployments:
1. Use external secrets for all sensitive information (database passwords, admin credentials, etc.)
2. Enable TLS/SSL for all communications
3. Configure proper RBAC and network policies
4. Use dedicated databases with proper access controls
5. Disable user registration if not needed
6. Use cloud storage for better scalability and backup
## Troubleshooting
Common issues and solutions:
1. **Health Check Issues / "No Available Server"**:
- Ensure `probes.*.httpHeaders` includes the correct Host header matching your domain
- Health checks use `/api/ping` endpoint which requires proper host validation
- Example fix:
```yaml
probes:
liveness:
httpHeaders:
- name: Host
value: your-joplin-domain.com
readiness:
httpHeaders:
- name: Host
value: your-joplin-domain.com
```
2. **Database connection issues**: Verify PostgreSQL credentials and network connectivity
3. **Storage permissions**: Check filesystem permissions for persistent volumes
4. **First admin user**: If no admin configured, access the web interface to create one
5. **Transcribe issues**: Verify Docker socket access and separate database configuration
6. **Origin validation errors**: Make sure `env.APP_BASE_URL` matches your ingress host
For detailed troubleshooting, check the application logs:
```bash
kubectl logs -f deployment/joplin-server
```
Check pod status and events:
```bash
kubectl describe pod -l app.kubernetes.io/name=joplin-server
```
## Backing Up
- **Database**: Use PostgreSQL backup tools (pg_dump, etc.)
- **File Storage**:
- Filesystem: Backup the PVC data
- S3: Files are already stored in S3 (ensure proper S3 backup policies)
- **Configuration**: Backup your Kubernetes secrets and config

View File

@ -0,0 +1,45 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "joplin-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "joplin-server.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 "joplin-server.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "joplin-server.labels" -}}
helm.sh/chart: {{ include "joplin-server.chart" . }}
{{ include "joplin-server.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "joplin-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "joplin-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -0,0 +1,377 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "joplin-server.fullname" . }}
labels:
{{- include "joplin-server.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
revisionHistoryLimit: {{ .Values.revisionHistoryLimit }}
selector:
matchLabels:
{{- include "joplin-server.selectorLabels" . | nindent 6 }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
metadata:
labels:
{{- include "joplin-server.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: 22300
protocol: TCP
{{- if .Values.probes.liveness.enabled }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
{{- if .Values.probes.liveness.httpHeaders }}
httpHeaders:
{{- toYaml .Values.probes.liveness.httpHeaders | nindent 16 }}
{{- end }}
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
{{- if .Values.probes.readiness.httpHeaders }}
httpHeaders:
{{- toYaml .Values.probes.readiness.httpHeaders | nindent 16 }}
{{- end }}
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:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- if .Values.postgresql.external.enabled }}
- name: POSTGRES_HOST
{{- if .Values.postgresql.external.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.external.existingSecret }}
key: {{ .Values.postgresql.external.hostKey }}
{{- else }}
value: {{ .Values.postgresql.external.host | quote }}
{{- end }}
- name: POSTGRES_PORT
{{- if .Values.postgresql.external.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.external.existingSecret }}
key: {{ .Values.postgresql.external.portKey }}
{{- else }}
value: {{ .Values.postgresql.external.port | quote }}
{{- end }}
- name: POSTGRES_DATABASE
{{- if .Values.postgresql.external.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.external.existingSecret }}
key: {{ .Values.postgresql.external.databaseKey }}
{{- else }}
value: {{ .Values.postgresql.external.database | quote }}
{{- end }}
- name: POSTGRES_USER
{{- if .Values.postgresql.external.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.external.existingSecret }}
key: {{ .Values.postgresql.external.userKey }}
{{- else }}
value: {{ .Values.postgresql.external.user | quote }}
{{- end }}
- name: POSTGRES_PASSWORD
{{- if .Values.postgresql.external.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.external.existingSecret }}
key: {{ .Values.postgresql.external.passwordKey }}
{{- else }}
value: {{ .Values.postgresql.external.password | quote }}
{{- end }}
{{- end }}
{{- if .Values.joplin.admin.email }}
- name: ADMIN_EMAIL
{{- if .Values.joplin.admin.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.joplin.admin.existingSecret }}
key: {{ .Values.joplin.admin.emailKey }}
{{- else }}
value: {{ .Values.joplin.admin.email | quote }}
{{- end }}
{{- end }}
{{- if .Values.joplin.admin.password }}
- name: ADMIN_PASSWORD
{{- if .Values.joplin.admin.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.joplin.admin.existingSecret }}
key: {{ .Values.joplin.admin.passwordKey }}
{{- else }}
value: {{ .Values.joplin.admin.password | quote }}
{{- end }}
{{- end }}
{{- if .Values.joplin.server.maxRequestBodySize }}
- name: MAX_REQUEST_BODY_SIZE
value: {{ .Values.joplin.server.maxRequestBodySize | quote }}
{{- end }}
{{- if .Values.joplin.server.sessionTimeout }}
- name: SESSION_TIMEOUT
value: {{ .Values.joplin.server.sessionTimeout | quote }}
{{- end }}
- name: ENABLE_USER_REGISTRATION
value: {{ .Values.joplin.server.enableUserRegistration | quote }}
- name: ENABLE_SHARING
value: {{ .Values.joplin.server.enableSharing | quote }}
- name: ENABLE_PUBLIC_NOTES
value: {{ .Values.joplin.server.enablePublicNotes | quote }}
{{- if eq .Values.joplin.storage.driver "filesystem" }}
- name: STORAGE_CONNECTION_STRING
value: "Type=Filesystem; Path={{ .Values.joplin.storage.filesystemPath }}"
{{- else if eq .Values.joplin.storage.driver "s3" }}
- name: STORAGE_CONNECTION_STRING
value: "Type=S3; Bucket={{ .Values.joplin.storage.s3.bucket }}; Region={{ .Values.joplin.storage.s3.region }}{{- if .Values.joplin.storage.s3.endpoint }}; Endpoint={{ .Values.joplin.storage.s3.endpoint }}{{- end }}"
{{- else }}
- name: STORAGE_CONNECTION_STRING
value: "Type=Database"
{{- end }}
{{- if and (eq .Values.joplin.storage.driver "s3") .Values.joplin.storage.s3.bucket }}
- name: AWS_ACCESS_KEY_ID
{{- if .Values.joplin.storage.s3.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.joplin.storage.s3.existingSecret }}
key: {{ .Values.joplin.storage.s3.accessKeyIdKey }}
{{- else }}
value: {{ .Values.joplin.storage.s3.accessKeyId | quote }}
{{- end }}
- name: AWS_SECRET_ACCESS_KEY
{{- if .Values.joplin.storage.s3.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.joplin.storage.s3.existingSecret }}
key: {{ .Values.joplin.storage.s3.secretAccessKeyKey }}
{{- else }}
value: {{ .Values.joplin.storage.s3.secretAccessKey | quote }}
{{- end }}
{{- end }}
{{- if .Values.joplin.email.enabled }}
- name: SMTP_HOST
value: {{ .Values.joplin.email.host | quote }}
- name: SMTP_PORT
value: {{ .Values.joplin.email.port | quote }}
- name: SMTP_USERNAME
{{- if .Values.joplin.email.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.joplin.email.existingSecret }}
key: {{ .Values.joplin.email.usernameKey }}
{{- else }}
value: {{ .Values.joplin.email.username | quote }}
{{- end }}
- name: SMTP_PASSWORD
{{- if .Values.joplin.email.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.joplin.email.existingSecret }}
key: {{ .Values.joplin.email.passwordKey }}
{{- else }}
value: {{ .Values.joplin.email.password | quote }}
{{- end }}
- name: SMTP_FROM_EMAIL
value: {{ .Values.joplin.email.fromEmail | quote }}
- name: SMTP_FROM_NAME
value: {{ .Values.joplin.email.fromName | quote }}
- name: SMTP_SECURE
value: {{ .Values.joplin.email.secure | quote }}
{{- end }}
{{- if .Values.transcribe.enabled }}
- name: TRANSCRIBE_ENABLED
value: "true"
- name: TRANSCRIBE_API_KEY
{{- if .Values.transcribe.api.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.transcribe.api.existingSecret }}
key: {{ .Values.transcribe.api.keyName }}
{{- else }}
value: {{ .Values.transcribe.api.key | quote }}
{{- end }}
- name: TRANSCRIBE_BASE_URL
value: "http://{{ include "joplin-server.fullname" . }}-transcribe:{{ .Values.transcribe.service.port }}"
{{- end }}
- name: LOG_LEVEL
value: {{ .Values.joplin.logging.level | quote }}
- name: LOG_TARGET
value: {{ .Values.joplin.logging.target | quote }}
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- if eq .Values.joplin.storage.driver "filesystem" }}
- name: data
mountPath: {{ .Values.joplin.storage.filesystemPath }}
{{- end }}
{{- if and .Values.security.tls.enabled .Values.security.tls.existingSecret }}
- name: tls-certs
mountPath: /app/certs
readOnly: true
{{- end }}
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if .Values.transcribe.enabled }}
- name: transcribe
image: "{{ .Values.transcribe.image.repository }}:{{ .Values.transcribe.image.tag }}"
imagePullPolicy: {{ .Values.transcribe.image.pullPolicy }}
ports:
- name: transcribe
containerPort: 4567
protocol: TCP
env:
- name: APP_PORT
value: "4567"
- name: DB_CLIENT
value: "pg"
- name: API_KEY
{{- if .Values.transcribe.api.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.transcribe.api.existingSecret }}
key: {{ .Values.transcribe.api.keyName }}
{{- else }}
value: {{ .Values.transcribe.api.key | quote }}
{{- end }}
- name: HTR_CLI_IMAGES_FOLDER
value: {{ .Values.transcribe.htr.imagesFolder | quote }}
{{- if .Values.transcribe.database.host }}
- name: QUEUE_DATABASE_HOST
{{- if .Values.transcribe.database.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.transcribe.database.existingSecret }}
key: {{ .Values.transcribe.database.hostKey }}
{{- else }}
value: {{ .Values.transcribe.database.host | quote }}
{{- end }}
- name: QUEUE_DATABASE_PORT
{{- if .Values.transcribe.database.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.transcribe.database.existingSecret }}
key: {{ .Values.transcribe.database.portKey }}
{{- else }}
value: {{ .Values.transcribe.database.port | quote }}
{{- end }}
- name: QUEUE_DATABASE_NAME
{{- if .Values.transcribe.database.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.transcribe.database.existingSecret }}
key: {{ .Values.transcribe.database.databaseKey }}
{{- else }}
value: {{ .Values.transcribe.database.database | quote }}
{{- end }}
- name: QUEUE_DATABASE_USER
{{- if .Values.transcribe.database.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.transcribe.database.existingSecret }}
key: {{ .Values.transcribe.database.userKey }}
{{- else }}
value: {{ .Values.transcribe.database.user | quote }}
{{- end }}
- name: QUEUE_DATABASE_PASSWORD
{{- if .Values.transcribe.database.existingSecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.transcribe.database.existingSecret }}
key: {{ .Values.transcribe.database.passwordKey }}
{{- else }}
value: {{ .Values.transcribe.database.password | quote }}
{{- end }}
{{- end }}
volumeMounts:
- name: transcribe-images
mountPath: {{ .Values.transcribe.htr.imagesFolder }}
- name: docker-socket
mountPath: /var/run/docker.sock
{{- end }}
volumes:
{{- if eq .Values.joplin.storage.driver "filesystem" }}
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "joplin-server.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
{{- if .Values.transcribe.enabled }}
- name: transcribe-images
{{- if .Values.transcribe.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "joplin-server.fullname" . }}-transcribe-images
{{- else }}
emptyDir: {}
{{- end }}
- name: docker-socket
hostPath:
path: /var/run/docker.sock
type: Socket
{{- end }}
{{- if and .Values.security.tls.enabled .Values.security.tls.existingSecret }}
- name: tls-certs
secret:
secretName: {{ .Values.security.tls.existingSecret }}
{{- 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 }}

View File

@ -0,0 +1,43 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "joplin-server.fullname" . }}
labels:
{{- include "joplin-server.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 "joplin-server.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,44 @@
{{- if and (eq .Values.joplin.storage.driver "filesystem") .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "joplin-server.fullname" . }}-data
labels:
{{- include "joplin-server.labels" . | nindent 4 }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- {{ .Values.persistence.accessMode | quote }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size | quote }}
{{- end }}
---
{{- if and .Values.transcribe.enabled .Values.transcribe.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "joplin-server.fullname" . }}-transcribe-images
labels:
{{- include "joplin-server.labels" . | nindent 4 }}
app.kubernetes.io/component: transcribe
{{- with .Values.transcribe.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- {{ .Values.transcribe.persistence.accessMode | quote }}
{{- if .Values.transcribe.persistence.storageClass }}
storageClassName: {{ .Values.transcribe.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.transcribe.persistence.size | quote }}
{{- end }}

View File

@ -0,0 +1,34 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "joplin-server.fullname" . }}
labels:
{{- include "joplin-server.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "joplin-server.selectorLabels" . | nindent 4 }}
---
{{- if .Values.transcribe.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "joplin-server.fullname" . }}-transcribe
labels:
{{- include "joplin-server.labels" . | nindent 4 }}
app.kubernetes.io/component: transcribe
spec:
type: {{ .Values.transcribe.service.type }}
ports:
- port: {{ .Values.transcribe.service.port }}
targetPort: transcribe
protocol: TCP
name: transcribe
selector:
{{- include "joplin-server.selectorLabels" . | nindent 4 }}
{{- end }}

View File

@ -0,0 +1,267 @@
## Global settings
nameOverride: ""
fullnameOverride: ""
## Image settings
image:
repository: joplin/server
tag: "3.4.2"
pullPolicy: IfNotPresent
## Deployment settings
replicaCount: 1
revisionHistoryLimit: 3
# Pod security settings
podSecurityContext:
runAsNonRoot: true
runAsUser: 1001
fsGroup: 1001
containerSecurityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
## Pod scheduling
nodeSelector: {}
tolerations: []
affinity: {}
## Service settings
service:
type: ClusterIP
port: 22300
## Ingress settings
ingress:
enabled: false
className: ""
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
hosts:
- host: joplin.domain.com
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- joplin.domain.com
## Resource limits and requests
# resources:
# limits:
# cpu: 500m
# memory: 512Mi
# requests:
# cpu: 100m
# memory: 256Mi
## Application health checks
probes:
liveness:
enabled: true
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
successThreshold: 1
path: /api/ping
# Host header for health checks to bypass origin validation
# Update this to match your actual domain
httpHeaders:
- name: Host
value: joplin.domain.com
readiness:
enabled: true
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
successThreshold: 1
path: /api/ping
# Host header for health checks to bypass origin validation
# Update this to match your actual domain
httpHeaders:
- name: Host
value: joplin.domain.com
## Autoscaling configuration
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 3
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80
## Environment variables
env:
# Application Settings
APP_PORT: "22300"
APP_BASE_URL: "http://localhost:22300"
# Database Settings (PostgreSQL required)
DB_CLIENT: "pg"
# Extra environment variables (for advanced use cases)
extraEnv: []
# Extra volume mounts
extraVolumeMounts: []
# Extra volumes
extraVolumes: []
## PostgreSQL configuration (External database required)
postgresql:
# External PostgreSQL settings (required)
external:
enabled: false
host: ""
port: 5432
database: "joplin"
user: "joplin"
password: ""
# Use existing secret for database credentials
existingSecret: ""
userKey: "username"
passwordKey: "password"
hostKey: "host"
portKey: "port"
databaseKey: "database"
## Joplin Server Configuration
joplin:
# Admin settings
admin:
# First admin user email (set during first setup)
email: ""
# First admin user password (set during first setup)
password: ""
# Use existing secret for admin credentials
existingSecret: ""
emailKey: "admin-email"
passwordKey: "admin-password"
# Server settings
server:
# Maximum request body size (in bytes)
maxRequestBodySize: "200mb"
# Session timeout in seconds
sessionTimeout: 86400
# Enable/disable user registration
enableUserRegistration: false
# Enable/disable sharing
enableSharing: true
# Enable/disable public notes
enablePublicNotes: true
# Storage settings
storage:
# Storage driver: database, filesystem, s3, or azure
driver: "database"
# For filesystem storage (requires persistence)
filesystemPath: "/var/lib/joplin"
# For S3 storage (optional)
s3:
bucket: ""
region: ""
accessKeyId: ""
secretAccessKey: ""
endpoint: ""
# Use existing secret for S3 credentials
existingSecret: ""
accessKeyIdKey: "access-key-id"
secretAccessKeyKey: "secret-access-key"
# Email settings (for user registration and notifications)
email:
enabled: false
host: ""
port: 587
username: ""
password: ""
fromEmail: ""
fromName: "Joplin Server"
# Use TLS/SSL
secure: true
# Use existing secret for email credentials
existingSecret: ""
usernameKey: "email-username"
passwordKey: "email-password"
# Logging settings
logging:
level: "info" # error, warn, info, debug
target: "console" # console, file
## Persistence settings (for filesystem storage)
persistence:
enabled: false
storageClass: ""
accessMode: ReadWriteOnce
size: 3Gi
annotations: {}
## Transcribe service (optional AI transcription)
transcribe:
enabled: false
image:
repository: joplin/transcribe
tag: "latest"
pullPolicy: IfNotPresent
# Transcribe API settings
api:
# Shared secret between Joplin Server and Transcribe service
key: ""
# Use existing secret for transcribe API key
existingSecret: ""
keyName: "transcribe-api-key"
# Transcribe service settings
service:
type: ClusterIP
port: 4567
# HTR CLI settings
htr:
# Images folder path
imagesFolder: "/app/images"
# Transcribe persistence (for image storage)
persistence:
enabled: false
storageClass: ""
accessMode: ReadWriteOnce
size: 5Gi
annotations: {}
# Transcribe database (separate from main Joplin database)
database:
host: ""
port: 5432
database: "transcribe"
user: "transcribe"
password: ""
# Use existing secret for transcribe database credentials
existingSecret: ""
userKey: "username"
passwordKey: "password"
hostKey: "host"
portKey: "port"
databaseKey: "database"
## Security settings
security:
# Enable/disable HTTPS redirect
httpsRedirect: false
# Custom TLS certificate
tls:
enabled: false
# Use existing secret for TLS certificate
existingSecret: ""
certificateKey: "tls.crt"
privateKeyKey: "tls.key"

View File

@ -1,57 +0,0 @@
ingress:
enabled: true
className: "traefik"
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
hosts:
- host: mealie.tomik.lat
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- mealie.tomik.lat
persistence:
enabled: true
storageClass: "longhorn"
accessMode: ReadWriteOnce
size: 3Gi
postgresql:
enabled: true
# External PostgreSQL settings
external:
enabled: true
host: "postgres-cluster-pooler.dbs.svc.cluster.local"
port: 5432
database: "mealie"
user: "mealie_user"
password: "7OemzeEtwYF1y7FyqRi6"
## Environment variables
env:
# General Settings
PUID: "911"
PGID: "911"
DEFAULT_GROUP: "Home"
DEFAULT_HOUSEHOLD: "Family"
BASE_URL: "http://localhost:9000"
TOKEN_TIME: "48"
API_PORT: "9000"
API_DOCS: "true"
TZ: "UTC"
ALLOW_SIGNUP: "false"
ALLOW_PASSWORD_LOGIN: "true"
LOG_LEVEL: "info"
DAILY_SCHEDULE_TIME: "23:45"
# Security
SECURITY_MAX_LOGIN_ATTEMPTS: "5"
SECURITY_USER_LOCKOUT_TIME: "24"
# Database
DB_ENGINE: "postgres"
# Webworker
UVICORN_WORKERS: "1"