Installing CodeScoring.Save in Kubernetes

CodeScoring.Save is installed using the save Helm chart. The same chart supports two deployment scenarios:

  • a standard Kubernetes installation with PostgreSQL, Redis, and S3-compatible object storage;
  • a hosted installation in a single-node k3s cluster with SQLite and filesystem storage.

Before installation, review the system requirements.

Installing with the Helm chart

The vendor provides the Helm repository and container registry URLs and access credentials. The commands below use these placeholders:

  • HELM_REPOSITORY_URL — Helm repository URL;
  • REGISTRY_URL — container registry URL;
  • USERNAME and PASSWORD — access credentials;
  • CHART_VERSION — Helm chart and CodeScoring.Save version.
  1. Create a namespace:

    kubectl create namespace codescoring-save --dry-run=client -o yaml | kubectl apply -f -
  2. Create a Secret for accessing the private container registry:

    kubectl create secret docker-registry codescoring-regcred \
      --namespace codescoring-save \
      --docker-server=REGISTRY_URL \
      --docker-username=USERNAME \
      --docker-password=PASSWORD

    The Secret name must match image.pullSecrets and osa-proxy.imagePullSecrets in values.yaml.

  3. Install Helm and add the CodeScoring.Save repository:

    helm repo add codescoring-save HELM_REPOSITORY_URL \
      --username USERNAME \
      --password PASSWORD
    helm repo update
  4. Download and unpack the chart:

    helm pull codescoring-save/save \
      --version CHART_VERSION \
      --untar \
      --untardir codescoring-save-src
    cd codescoring-save-src/save

Configuring Helm chart values

Important

Make the required changes before the first installation. Before upgrading an existing installation, back up PostgreSQL and the object storage.

The complete installation configuration is stored in values.yaml. The examples below include only the fragments to change; preserve the remaining settings from the distributed file.

Container images

The main components use a shared registry, tag, and image pull Secret:

image:
  registry: REGISTRY_URL/save-docker
  tag: CHART_VERSION
  pullSecrets:
    - name: codescoring-regcred

OSA Proxy is distributed as a chart dependency and has separate image settings:

osa-proxy:
  image:
    repository: REGISTRY_URL/save-docker/osa-proxy
    tag: OSA_PROXY_VERSION
  imagePullSecrets:
    - name: codescoring-regcred

Use the image versions specified in the distributed values.yaml; they are tested for compatibility with the selected chart version.

Application secrets

Sensitive backend, worker, scheduler, and auth settings are stored in secrets.save-secrets.data:

secrets:
  save-secrets:
    enabled: true
    data:
      SECRETS_ENCRYPTION_KEY: <STABLE_ENCRYPTION_KEY>
      DATABASE_NAME: save_db
      DATABASE_USER: save_user
      DATABASE_PASSWORD: <DATABASE_PASSWORD>
      REDIS_PASSWORD: ""
      S3_ACCESS_KEY: <S3_ACCESS_KEY>
      S3_SECRET_KEY: <S3_SECRET_KEY>
      AUTH_INTERNAL_SECRET: <AUTH_INTERNAL_SECRET>
      AUTH_ADMIN_PASSWORD: <ADMIN_PASSWORD>

SECRETS_ENCRYPTION_KEY encrypts stored credentials. Generate it once, for example with openssl rand -base64 32, store it securely, and do not change it during upgrades. Previously stored data cannot be decrypted after the key is changed.

Do not keep production passwords and keys in version control. For a production installation, use a protected values file or External Secrets.

JWT signing key

On the first start, the cs-auth service creates an RSA-2048 key and stores it in its database. On restart and on other replicas, the key is read from there.

Protect database backups

The key is stored in the database without encryption. A dump or backup of the database (including a database shared with Save) makes it possible to issue tokens on behalf of any user — store them as secrets.

Key rotation is available to the administrator through the API (an administrator token is required, see Getting an Admin JWT):

# List the keys and their state (without the private part)
curl -sS https://save.example.com/api/v1/admin/signing-keys \
  -H "Authorization: Bearer $ADMIN_JWT"

# Planned rotation: nobody is logged out
curl -sS -X POST https://save.example.com/api/v1/admin/signing-keys/rotate \
  -H "Authorization: Bearer $ADMIN_JWT"

# Emergency rotation after a key leak: all issued access tokens become invalid immediately
curl -sS -X POST https://save.example.com/api/v1/admin/signing-keys/rotate \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -d '{"immediate": true}'

During a planned rotation, the new key is first published in JWKS and starts signing tokens 2 minutes later. The old key keeps verifying already issued tokens until they expire and is then removed automatically. During an emergency rotation, refresh tokens keep working, so the web interface simply obtains a new token pair. Every rotation is recorded in the audit log.

PostgreSQL and Redis from the Helm chart

By default, the chart creates PostgreSQL and Redis as StatefulSets. Enable their resources and configure the PersistentVolumeClaims:

statefulSets:
  save-postgresql:
    enabled: true
  save-redis:
    enabled: true

services:
  redis:
    enabled: true

pvcs:
  save-postgresql:
    enabled: true
    accessModes:
      - ReadWriteOnce
    size: 20Gi
    storageClassName: "default"
  save-redis:
    enabled: true
    accessModes:
      - ReadWriteOnce
    size: 2Gi
    storageClassName: "default"

Replace storageClassName and the volume sizes according to the cluster configuration and expected data volume.

The built-in PostgreSQL credentials are configured separately and must match DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD in secrets.save-secrets.data:

secrets:
  postgresql-secrets:
    enabled: true
    data:
      POSTGRES_DB: save_db
      POSTGRES_USER: save_user
      POSTGRES_PASSWORD: <DATABASE_PASSWORD>

Application connection settings for PostgreSQL and Redis are stored in the save-backend-envs ConfigMap:

configMaps:
  save-backend-envs:
    enabled: true
    data:
      DATABASE_DRIVER: "postgres"
      DATABASE_HOST: "save-postgresql"
      DATABASE_PORT: "5432"
      REDIS_ENABLED: "true"
      REDIS_ADDR: "save-redis:6379"
      REDIS_DB: "0"

The ConfigMap and Secret are already attached to the components through deploymentsGeneral.envConfigmaps and deploymentsGeneral.envSecrets. Preserve these references when editing the complete values.yaml:

deploymentsGeneral:
  envConfigmaps:
    - save-backend-envs
  envSecrets:
    - save-secrets

External PostgreSQL and Redis

For an external PostgreSQL server, disable the built-in StatefulSet and PVC and change the connection settings:

statefulSets:
  save-postgresql:
    enabled: false

pvcs:
  save-postgresql:
    enabled: false

configMaps:
  save-backend-envs:
    data:
      DATABASE_DRIVER: "postgres"
      DATABASE_HOST: "postgresql.example.com"
      DATABASE_PORT: "5432"

secrets:
  save-secrets:
    data:
      DATABASE_NAME: save_db
      DATABASE_USER: save_user
      DATABASE_PASSWORD: <DATABASE_PASSWORD>

Create the database and user before installing CodeScoring.Save.

For an external Redis server, disable the built-in StatefulSet, Service, and PVC:

statefulSets:
  save-redis:
    enabled: false

services:
  redis:
    enabled: false

pvcs:
  save-redis:
    enabled: false

configMaps:
  save-backend-envs:
    data:
      REDIS_ENABLED: "true"
      REDIS_ADDR: "redis.example.com:6379"
      REDIS_DB: "0"

secrets:
  save-secrets:
    data:
      REDIS_PASSWORD: <REDIS_PASSWORD>

S3-compatible object storage

The current chart does not deploy an S3-storage. For a standard Kubernetes installation, create a bucket in an external S3-compatible storage service before installation and specify its connection settings:

configMaps:
  save-backend-envs:
    data:
      STORAGE_TYPE: "s3"
      S3_REGION: "us-east-1"
      S3_BUCKET: "save"
      S3_ENDPOINT: "https://s3.example.com"
      S3_FORCE_PATH_STYLE: "true"

secrets:
  save-secrets:
    data:
      S3_ACCESS_KEY: <S3_ACCESS_KEY>
      S3_SECRET_KEY: <S3_SECRET_KEY>

For storage services that support virtual-hosted-style URLs, set S3_FORCE_PATH_STYLE: "false".

The S3_REGION parameter is required. For MinIO and other storage services without regions, specify any value, for example us-east-1. To place several installations in one bucket, give each of them its own key prefix in S3_PREFIX.

Large file uploads

Save writes a file that does not exceed one part (8 MiB by default) to S3 with a single request. A larger file is written with a multipart upload: it is split into parts, and several parts are sent in parallel. As a result, Save stores artifacts larger than 5 GB (the S3 limit is 5 TiB per object) and is not affected by the MinIO limit of 16 MiB per chunk (the chunk too big error).

The default values usually fit. If needed, change them in configMaps.save-backend-envs.data:

ParameterDefaultAllowedMeaning
S3_MULTIPART_PART_SIZE_MB85–5120Size of one part in MiB. Files that do not exceed this size are uploaded with a single request
S3_MULTIPART_CONCURRENCY21–64How many parts of one file are sent at the same time
S3_MULTIPART_MAX_INFLIGHT16-1, 1…How many multipart uploads one pod runs at the same time. The rest wait in a queue. -1 means no limit
configMaps:
  save-backend-envs:
    data:
      S3_MULTIPART_PART_SIZE_MB: "8"
      S3_MULTIPART_CONCURRENCY: "2"
      S3_MULTIPART_MAX_INFLIGHT: "16"

Keep in mind:

  • Memory. One multipart upload takes about (S3_MULTIPART_CONCURRENCY + 2) × S3_MULTIPART_PART_SIZE_MB, and a pod in the worst case runs S3_MULTIPART_MAX_INFLIGHT such uploads. With the default values, this is 16 × 4 × 8 MiB = 512 MiB. Take this into account in resources.limits.memory for backend and worker. S3 allows no more than 10,000 parts, so for very large files (over ~78 GiB with 8 MiB parts) the part size grows automatically, and such an upload needs proportionally more memory.
  • MinIO. Do not set S3_MULTIPART_PART_SIZE_MB above 16: MinIO over https rejects chunks larger than 16 MiB.
  • Temporary files. Before sending a file to S3, Save often stores the incoming file in the pod temporary directory (/tmp by default): this is how Docker layers, RPM and Composer packages, and any streams that cannot be re-read are processed. The whole file is never kept in memory, but the disk needs free space of at least the total size of the files uploaded at the same time. In Kubernetes, this is the node ephemeral storage: reserve enough ephemeral-storage for backend or mount a separate volume and set its path in the TMPDIR variable.
  • Request time. Save waits for a single HTTP request for no longer than 10 minutes — within this time, the file must be both received and written to S3. For example, a 5 GB file requires at least ~10 MB/s. The ingress and load balancer timeouts must be at least as long (see Ingress).
  • Interrupted uploads. If an upload breaks off, Save aborts the multipart upload in S3. If the abort fails (for example, S3 was unavailable), the parts already uploaded stay in the bucket and take up space. In AWS S3 and Yandex Object Storage, configure an AbortIncompleteMultipartUpload lifecycle rule for the bucket (for example, 1 day). MinIO removes incomplete uploads by itself (the stale_uploads_expiry parameter, 24 hours by default).
  • Checksums. If S3_ENDPOINT is set, Save adds checksums to requests only when the storage requires them — this improves compatibility with MinIO, Ceph, and other S3 storage services. The behavior can be changed with the standard AWS SDK variables AWS_REQUEST_CHECKSUM_CALCULATION and AWS_RESPONSE_CHECKSUM_VALIDATION (when_supported or when_required).
  • Custom S3 certificate. If S3 is served over https with a certificate issued by a corporate certificate authority, set the path to its certificate in AWS_CA_BUNDLE.

Hosted installation in k3s

The hosted option is intended for a single-server k3s installation and uses SQLite for metadata and the local filesystem for artifacts.

Install k3s and configure cluster access:

curl -sfL https://get.k3s.io | sh -
sudo systemctl status k3s

mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown "$USER:$USER" ~/.kube/config
export KUBECONFIG=~/.kube/config

kubectl get nodes

To switch the standard chart profile to hosted, change three variables in configMaps.save-backend-envs.data:

configMaps:
  save-backend-envs:
    data:
      DATABASE_DRIVER: "sqlite"
      DATABASE_SQLITE_PATH: "./data/repository_manager.db"
      STORAGE_TYPE: "filesystem"

The save-backend-envs ConfigMap is attached to backend, worker, scheduler, and auth, so no additional ConfigMap is required. Preserve the other settings from the distributed values.yaml.

The worker and scheduler components require PostgreSQL and do not support SQLite. Both deployments must be disabled in a hosted installation:

deployments:
  worker:
    enabled: false
  scheduler:
    enabled: false

Persistent data storage

A hosted installation requires three separate persistent volumes:

  • db-data — the backend SQLite database;
  • auth-data — the auth SQLite database;
  • storage — filesystem artifact storage.

Attach the PVCs to the deployments through volumes and volumeMounts. For auth, also set an empty initContainers value to remove the inherited wait-for-postgres init container:

deployments:
  save:
    containers:
      save:
        volumeMounts:
          - name: db-data
            mountPath: /app/data
          - name: storage
            mountPath: /app/storage
    volumes:
      - name: db-data
        type: pvc
      - name: storage
        type: pvc

  auth:
    initContainers:
    containers:
      auth:
        volumeMounts:
          - name: auth-data
            mountPath: /app/data
    volumes:
      - name: auth-data
        type: pvc

Backend and auth use the same DATABASE_SQLITE_PATH value but separate PVCs, so each service stores its own database in its /app/data directory. The default STORAGE_ROOT_PATH is ./storage, which corresponds to the /app/storage mount point when the working directory is /app.

For Dynamic Volume Provisioning, specify a StorageClass for each PVC. The corresponding PVs will be created automatically:

pvcs:
  db-data:
    accessModes:
      - ReadWriteOnce
    storageClassName: "default"
    size: 5Gi
  auth-data:
    accessModes:
      - ReadWriteOnce
    storageClassName: "default"
    size: 1Gi
  storage:
    accessModes:
      - ReadWriteOnce
    storageClassName: "default"
    size: 10Gi

Choose the volume sizes and storageClassName according to the workload and cluster configuration.

You can also use pre-created PVs of any Kubernetes-supported type. In this case, create the PVs separately or describe them under pvs, and set an empty storageClassName and the corresponding volumeName in each PVC, for example:

pvs:
  db-data:
    accessModes:
      - ReadWriteMany
    size: 5Gi
    storageClassName: ""
    mountOptions:
      - nfsvers=4.2
    nfs:
      server: nfs.example.com
      path: /exports/codescoring-save/db-data

pvcs:
  db-data:
    accessModes:
      - ReadWriteMany
    storageClassName: ""
    volumeName: codescoring-save-db-data
    size: 5Gi

In this example, for the codescoring-save Helm release, the chart creates the codescoring-save-db-data PV from pvs.db-data, and the PVC binds to it explicitly through volumeName. Replace the NFS server address and export path with values from your infrastructure.

Create and bind the auth-data and storage volumes in the same way. The volume source can be NFS, CSI, a local disk, or another storage system; its parameters depend on the cluster infrastructure.

Optional Redis

Redis is optional for a hosted installation. To run Save without Redis, disable it in the application configuration and disable the related StatefulSet, Service, and PVC:

configMaps:
  save-backend-envs:
    data:
      REDIS_ENABLED: "false"

statefulSets:
  save-redis:
    enabled: false

services:
  redis:
    enabled: false

pvcs:
  save-redis:
    enabled: false

If Redis is used, keep REDIS_ENABLED: "true", and leave its StatefulSet, Service, and PVC enabled.

SQLite supports a single replica. Do not increase deploymentsGeneral.replicas, and disable autoscaling for backend and worker:

deploymentsGeneral:
  replicas: 1

hpas:
  save:
    enabled: false
  worker:
    enabled: false

The application no longer uses PostgreSQL after this switch. You can also disable its unused StatefulSet and PVC:

statefulSets:
  save-postgresql:
    enabled: false

pvcs:
  save-postgresql:
    enabled: false
Important

SQLite and filesystem storage are suitable only for a single-server installation. Keep the SQLite database and artifact directories on persistent k3s storage and back them up regularly. Do not use this profile for a multi-replica deployment.

Ingress

Ingress is disabled by default. Enable it and replace the domain name to expose the web interface and API:

ingresses:
  ingress:
    enabled: true
    ingressClassName: nginx
    hosts:
      - hostname: save.example.com
        paths:
          - path: /
            serviceName: frontend
            servicePort: 8081
    tls:
      - hosts:
          - save.example.com
        secretName: save-tls

configMaps:
  save-frontend-envs:
    data:
      NGINX_HOST: "save.example.com"
      NGINX_PORT: "8081"

If TLS is terminated by an external load balancer, configure tls and the Ingress annotations according to the cluster infrastructure.

To let large files (Docker layers, archives, packages) pass through the Ingress, remove the request size limit and increase the timeouts to 10 minutes — this is how long Save waits for a single request. Example for ingress-nginx:

ingresses:
  ingress:
    annotations:
      nginx.ingress.kubernetes.io/proxy-body-size: "0"
      nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
      nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
      nginx.ingress.kubernetes.io/proxy-send-timeout: "600"

Without these settings, ingress-nginx rejects requests larger than 1 MB by default with the 413 Request Entity Too Large error.

HTTPRoute (Gateway API)

If the cluster uses Kubernetes Gateway API, configure a route to the frontend through the httpRoutes section. The referenced Gateway resource must exist before CodeScoring.Save is installed.

httpRoutesGeneral: {}
httpRoutes:
  save:
    gatewayName: codescoring-gateway
    gatewayNamespace: gateway-system
    gatewaySectionName: https
    gatewayKind: Gateway
    gatewayGroup: gateway.networking.k8s.io
    hostnames:
      - "save.example.com"
    rules:
      - matches:
          - path:
              type: PathPrefix
              value: /
        backendRefs:
          - name: codescoring-save-frontend
            port: 8081

configMaps:
  save-frontend-envs:
    data:
      NGINX_HOST: "save.example.com"
      NGINX_PORT: "8081"

Set gatewayName, gatewayNamespace, gatewaySectionName, and hostnames to match the Gateway and DNS name in your cluster.

Important

The backendRefs.name value must be formed as {helm-release-name}-frontend. For example, with helm install codescoring-save ..., specify name: codescoring-save-frontend.

OSA Proxy

OSA Proxy is disabled by default. To enable it set the osa-proxy.enabled field to true. In the existing osa-proxy.config.content block, replace codescoring.url with the CodeScoring installation URL. Pass the token through the osa-proxy Secret:

osa-proxy:
  enabled: true
  secret:
    create: true
    stringData:
      CODESCORING_TOKEN: <CODESCORING_TOKEN>

If component checks through OSA Proxy are not required, disable the dependency:

osa-proxy:
  enabled: false

osa-proxy.config.content contains the complete service configuration. Preserve the remaining sections from the distributed values.yaml when modifying it.

Resource limits

The distributed values.yaml does not limit the main component containers. For a production installation, set requests and limits for every component based on load testing:

deployments:
  save:
    containers:
      save:
        resources:
          requests:
            cpu: 500m
            memory: 1Gi
          limits:
            cpu: "2"
            memory: 4Gi
  worker:
    containers:
      worker:
        resources:
          requests:
            cpu: 500m
            memory: 1Gi
          limits:
            cpu: "2"
            memory: 4Gi

Configure deployments.scheduler, deployments.auth, deployments.save-frontend, statefulSets.save-postgresql, and statefulSets.save-redis in the same way. CPU requests must be defined for HPA to work correctly with scalable containers.

External Secrets

The chart can obtain secrets from an external store through External Secrets Operator. Install the operator and create a SecretStore or ClusterSecretStore in the cluster first.

Enable the required resource under vaults:

vaults:
  save-secrets-external:
    apiVersion: external-secrets.io/v1
    enabled: true
    store:
      name: vault-backend
      kind: ClusterSecretStore
    path: save-secrets

Then attach the generated Secret to the deployments:

deploymentsGeneral:
  envConfigmaps:
    - save-backend-envs
  envSecrets:
    - save-secrets-external

For the built-in PostgreSQL, configure vaults.postgresql-secrets-external in the same way and attach the Secret under statefulSets.save-postgresql.envSecrets.

Installation

From the unpacked chart directory, run:

helm install codescoring-save . \
  --namespace codescoring-save \
  --values values.yaml \
  --atomic

To update an existing installation, use the same configured values.yaml version:

helm upgrade codescoring-save . \
  --namespace codescoring-save \
  --values values.yaml \
  --atomic

Verifying the installation

Check the resource status:

kubectl get pods -n codescoring-save
kubectl get statefulset,pvc -n codescoring-save
kubectl get service,ingress -n codescoring-save

All Pods must reach the Running state and their containers must be ready. Use component logs for diagnostics:

kubectl logs -n codescoring-save -l app=backend --tail=100
kubectl logs -n codescoring-save -l app=worker --tail=100
kubectl logs -n codescoring-save -l app=scheduler --tail=100
kubectl logs -n codescoring-save -l app=auth --tail=100
kubectl logs -n codescoring-save -l app=frontend --tail=100

After installation, open the Ingress URL, sign in with the password specified in AUTH_ADMIN_PASSWORD, and enter the activation key.

Activation key

Until the activation key is entered, Save responds with 403 and the No valid license message to API and repository requests. Without the key, only sign-in, health checks, Swagger, and the activation key page work.

The activation key is provided by the vendor. There are two ways to enter it.

In the web interface.

  1. Open Settings -> Activation key.
  2. Click Add new activation key in the top-right corner.
  3. Paste the key into the Enter new key here field and click Activate.

The new key replaces the current one. The Activation key page shows the key state (Status), the issue date (Issued at), the validity period (Expires in), and the number of artifacts (Artifacts).

Through the API:

curl -sS -X POST https://save.example.com/api/v1/license \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -d '{"license": "<activation key>"}'

The response contains the key state: is_valid, the owner (owner), the issued_at and expires_at dates, the remaining time expires_in (in milliseconds), and the message explanation.

To check the key state and the artifact counter:

curl -sS https://save.example.com/api/v1/license -H "Authorization: Bearer $ADMIN_JWT"
curl -sS https://save.example.com/api/v1/license/usage -H "Authorization: Bearer $ADMIN_JWT"
Several backend replicas without Redis

Each pod remembers the result of the activation key check for up to 2 hours. With Redis, a new key is visible to all pods immediately. Without Redis, a pod that has already remembered that there is no key may respond with 403 for up to 2 hours — after entering the key, restart the backend pods: kubectl rollout restart deployment -n codescoring-save.

Next steps

After a successful installation:

  1. Create the first repository.
  2. Configure cleanup policies.
  3. Create users and assign roles.
Was this page helpful?