From Docker Compose to GitOps: Managing Nextcloud with FluxCD & K3s

How I moved my Nextcloud setup from Docker Compose to K3s by using FluxCD and overlays. I'll walk you through my GitOps flow of syncing from GitHub, managing credentials with sealed secrets and automate the deployment.

From Docker Compose to GitOps: Managing Nextcloud with FluxCD & K3s
From Docker Compose to GitOps: Managing Nextcloud with FluxCD & K3s

This post has been updated due to changes in some of the YAML files. Scroll to the bottom for a detailed changelog.

For the longest time I have been using Docker Compose. It's simple, familiar, and gets the job done. But I wanted to make the switch to Kubernetes and automate the deployments. This is where k3s, a lightweight Kubernetes Distro comes in. In this article I will explain how I manage my Nextcloud deployment with FluxCD and Overlays.

FluxCD is a GitOps tool that enables us to sync git repositories into our cluster. The idea is to put our manifests in a git repository, and structuring the repo in a monorepo approach. You commit changes to this repository and Flux will sync them to the cluster.

FluxCD Kustomization is used to tell FluxCD how to apply the Kubernetes manifest stored in our monorepo. It is a central component for our Flux GitOps workflow as it will tell Flux where to find the manifests, how to deploy them and how often to check for updates.

In my Nextcloud deployment example, the Kustomization resource will target the overlay directory (./apps/overlays/rudolf/nextcloud) and make sure that all Nextcloud resources located in the base directory (./apps/base/nextcloud/) are deployed in the targetNamespace: rudolf-nextcloud and ensure they are configured with the correct credentials from the rudolf-nextcloud-sealed-secret.

I won't bore you to sleep with installing k3s and bootstrapping FluxCD, but you will need a Kubernetes Cluster with FluxCD bootstrapped for GitHub and Bitnami’s sealed-secrets controller to store secrets safely.

GitOps Workflow Overview

Here is a brief simplified explanation on my GitOps Workflow works. FluxCD is running on my k3s cluster:

Role Node Name
Control Plane node1-gryffyndor
Worker node2-ravenclaw
Worker node3-hufflepuff
Worker node4-slytherin

FluxCD monitors my Github Repository for changes. It will compare and adjust the state of the Kubernetes cluster. This process is called Reconciliation and allows FluxCD to carry out the configuration found in the Github Repository.

GitOps Workflow

Monorepo

To manage Nextcloud deployments I will use a monorepo approach outlined by FluxCD. These are the main components I used for a Nextcloud installation on Kubernetes:

The database, Nextcloud and Redis service will have its own PersistentVolumeClaim and Service. The configurations all reference the same SealedSecret called nextcloud-mariadb-secret. This SealedSecret is located in the overlays directory and customized for each client or environment.

The base folder holds YAML installation configuration, like deployment.yaml, service.yaml. The YAML files stored in this directory will not have a namespace in their metadata.

The overlays folder contains environment-specific overlays, like namespace and database variables such as username and passwords that are stored in a sealed secret.

├── apps
│   └── base
│       └── nextcloud
│           ├── kustomization.yaml
│           ├── nextcloud-deployment.yaml
│           ├── nextcloud-mariadb.yaml
│           ├── nextcloud-nextcloud.yaml
│           ├── nextcloud-pvc-db.yaml
│           ├── nextcloud-pvc-nc.yaml
│           ├── nextcloud-pvc-redis.yaml
│           ├── nextcloud-redis.yaml
│           ├── nextcloud-svc-db.yaml
│           ├── nextcloud-svc-nc.yaml
│           ├── nextcloud-svc-redis.yaml
│           ├── nextcloud-volume.yaml
│           ├── nextcloud-cert.yaml
│           ├── nextcloud-ingress.yaml
│           └── nextcloud-cron.yaml
└── overlays
    ├── rudolf
    │   └── nextcloud
    │       ├── kustomization.yaml
    │       ├── nextcloud-namespace.yaml
    │       └── nextcloud-sealed-secret.yaml
    └── other
        └── nextcloud
            ├── kustomization.yaml
            ├── nextcloud-namespace.yaml
            └── nextcloud-sealed-secret.yaml

Securing Secrets with Sealed Secrets

The heart of this deployment revolves around the SealedSecret. We start by creating an Opaque Secret that contains sensitive information like database credentials and admin passwords. Here’s an example of what the unencrypted secret looks like:

apiVersion: v1
kind: Secret
metadata:
  name: nextcloud-mariadb-secret
  namespace: rudolf-nextcloud
type: Opaque
stringData:
  mysql-host: mariadb.rudolf-nextcloud.svc.cluster.local
  mysql-database: nextcloud
  mysql-user: nextcloud
  mysql-password: MYSQLPASSWORD
  redis-host: redis.rudolf-nextcloud.svc.cluster.local
  nextcloud_admin_user: rudolf
  nextcloud_admin_password: ADMINPASSWORD

This Secret contains the following fields:

Field Name Description
mysql-host Hostname of the MariaDB server
mysql-database Name of the Nextcloud database
mysql-user Username for the MariaDB database
mysql-password Password for the MariaDB user
redis-host Hostname of the Redis server
nextcloud_admin_user Username for the Nextcloud admin account
nextcloud_admin_password Password for the Nextcloud admin account

To secure this secret in Git, we encrypt it using kubeseal, that is included in the sealed-secrets controller. First, find your the controller’s public key (certificate) with:

kubeseal --fetch-cert > controller-cert.pem

Then, use kubeseal to encrypt the secret:

kubeseal --cert=/home/rudolf/controller-cert.pem < nextcloud-secret.yaml > nextcloud-sealed-secret.yaml

This creates a SealedSecret that can safely be committed to your Git repository. The SealedSecret and Secret must have the same namespace and name, as the sealed-secrets controller uses this information to correctly map and decrypt the secret when applying it to your cluster.

Important: Once you’ve created the SealedSecret, delete the original Secret file to avoid accidentally exposing sensitive information.

Deployment

Step 1: FluxCD GitRepository

To deploy Nextcloud using FluxCD will we need to tell Flux where to pull the Kubernetes manifests from. It connects my k3s cluster to a Git repository, therefore acting as the source of truth for all the configurations that FluxCD will apply and manage.

apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
  name: rudolf-nextcloud-repo
  namespace: default
spec:
  interval: 1m
  url: https://github.com/rud3olph/hogwarts
  ref:
    branch: main
  secretRef:
    name: git-access-auth

This GitRepository resource does the following:

Field Description
url Points FluxCD to the GitHub repository containing the Nextcloud manifests
interval: 1m Tells Flux to check for changes every minute
ref Ensures Flux tracks the main branch
secretRef Allows Flux to authenticate securely if the repository is private

Step 2: FluxCD Kustomization

Now we have to define how FluxCD should apply these Kubernetes manifests stored in the GitRepository to the k3s cluster.

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: rudolf-nextcloud-kustomization
  namespace: default
spec:
  interval: 1m
  targetNamespace: rudolf-nextcloud
  sourceRef:
    kind: GitRepository
    name: rudolf-nextcloud-repo
  path: "./apps/overlays/rudolf/nextcloud"   
  prune: true
  wait: true
  timeout: 1m

This Kustomization resource does the following:

Field Description
name Unique identifier for the FluxCD Kustomization
namespace Namespace where the Kustomization resource is created (default)
interval How often Flux checks for repository changes (1 minute)
targetNamespace Destination namespace for deployed resources (rudolf-nextcloud)
sourceRef Reference to the GitRepository source (rudolf-nextcloud-repo)
path Repository path containing manifests (./apps/overlays/rudolf/nextcloud)

Step 3: Kustomize processes Resources in Order

Kubernetes Kustomize will start deploying the listed resources after apply both the FluxCD Gitrepository and Kustomization:

File/Path Purpose
rudolf-nextcloud-namespace.yaml Creates the namespace for Nextcloud deployment
rudolf-nextcloud-sealed-secret.yaml Deploys the SealedSecret containing credentials
../../../base/nextcloud References the base kustomization.yaml to apply standard Nextcloud configurations

Base Resources:

Click to expand: nextcloud-mariadb.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mariadb
spec:
serviceName: "mariadb"
replicas: 1
selector:
  matchLabels:
    app: mariadb
template:
  metadata:
    labels:
      app: mariadb
  spec:
    containers:
    - name: mariadb
      image: mariadb:10.11
      env:
      - name: MYSQL_ROOT_PASSWORD
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: mysql-password
      - name: MYSQL_DATABASE
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: mysql-database
      - name: MYSQL_USER
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: mysql-user
      - name: MYSQL_PASSWORD
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: mysql-password
      - name: REDIS_HOST
        value: redis
      ports:
      - containerPort: 3306
      resources:
        requests:
          memory: "4Gi"
          cpu: "250m"
      volumeMounts:
      - name: mariadb-data
        mountPath: /var/lib/mysql
    volumes:
    - name: mariadb-data
      persistentVolumeClaim:
        claimName: mariadb-pvc
    nodeSelector:
      node-role: worker-slytherin
Click to expand: nextcloud-pvc-db.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-pvc
spec:
accessModes:
  - ReadWriteOnce
resources:
  requests:
    storage: 20Gi
Click to expand: nextcloud-svc-db.yaml
apiVersion: v1
kind: Service
metadata:
name: mariadb
spec:
type: ClusterIP
ports:
- port: 3306
  targetPort: 3306
selector:
  app: mariadb
Click to expand: nextcloud-redis.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
spec:
serviceName: "redis"
replicas: 1
selector:
  matchLabels:
    app: redis
template:
  metadata:
    labels:
      app: redis
  spec:
    containers:
    - name: redis
      image: redis:7.4.2
      ports:
      - containerPort: 6379
      resources:
        requests:
          memory: "2Gi"
          cpu: "250m"
      volumeMounts:
      - name: redis-data
        mountPath: /data
    volumes:
    - name: redis-data
      persistentVolumeClaim:
        claimName: redis-pvc
    nodeSelector:
      node-role: worker-slytherin
Click to expand: nextcloud-pvc-redis.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
spec:
accessModes:
  - ReadWriteOnce
resources:
  requests:
    storage: 5Gi
Click to expand: nextcloud-svc-redis.yaml
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
type: ClusterIP
ports:
- port: 6379
  targetPort: 6379
selector:
  app: redis
Click to expand: nextcloud-nextcloud.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nextcloud
spec:
serviceName: "nextcloud"
replicas: 1
selector:
  matchLabels:
    app: nextcloud
template:
  metadata:
    labels:
      app: nextcloud
  spec:
    containers:
    - name: nextcloud
      image: nextcloud:apache
      imagePullPolicy: Always
      env:
      - name: MYSQL_HOST
        value: mariadb
      - name: MYSQL_DATABASE
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: mysql-database
      - name: MYSQL_USER
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: mysql-user
      - name: MYSQL_PASSWORD
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: mysql-password
      - name: NEXTCLOUD_ADMIN_USER
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: nextcloud_admin_user
      - name: NEXTCLOUD_ADMIN_PASSWORD
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: nextcloud_admin_password
      - name: NEXTCLOUD_TRUSTED_DOMAINS
        valueFrom:
          secretKeyRef:
            name: nextcloud-mariadb-secret
            key: nextcloud_trusted_domains
      - name: OVERWRITEPROTOCOL
        value: https
      - name: REDIS_HOST
        value: redis
      - name: REDIS_PORT
        value: "6379"
      ports:
      - containerPort: 80
      resources:
        requests:
          memory: "8Gi"
          cpu: "500m"
      volumeMounts:
      - name: nextcloud-data
        mountPath: /var/www/html
    volumes:
    - name: nextcloud-data
      persistentVolumeClaim:
        claimName: nextcloud-pvc
    nodeSelector:
      node-role: worker-slytherin
Click to expand: nextcloud-pvc-nc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nextcloud-pvc
spec:
accessModes:
  - ReadWriteOnce
resources:
  requests:
    storage: 10Gi
Click to expand: nextcloud-svc-nc.yaml
apiVersion: v1
kind: Service
metadata:
name: nextcloud
spec:
type: ClusterIP
ports:
- port: 80
  targetPort: 80
selector:
  app: nextcloud
Click to expand: nextcloud-volume.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: nc-pv
spec:
capacity:
  storage: 500Gi
volumeMode: Filesystem
accessModes:
  - ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
  path: /home/rudolf/nc
nodeAffinity:
  required:
    nodeSelectorTerms:
    - matchExpressions:
      - key: node-role
        operator: In
        values:
        - worker-slytherin
Click to expand: nextcloud-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: nextcloud-tls
spec:
secretName: nextcloud-tls
issuerRef:
  name: letsencrypt-dns
  kind: ClusterIssuer
dnsNames:
  - nextcloud.roomofrequirement.nl
Click to expand: nextcloud-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nextcloud
annotations:
  kubernetes.io/ingress.class: "nginx"
  cert-manager.io/cluster-issuer: "letsencrypt-dns"
  nginx.ingress.kubernetes.io/proxy-body-size: "10G"
spec:
ingressClassName: nginx
tls:
  - hosts:
      - nextcloud.roomofrequirement.nl
    secretName: nextcloud-tls
rules:
  - host: nextcloud.roomofrequirement.nl
    http:
      paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: nextcloud
              port:
                number: 80
Click to expand: nextcloud-cron.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: nextcloud-cron
spec:
schedule: "*/5 * * * *"
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
  spec:
    template:
      spec:
        containers:
        - name: nextcloud-cron
          image: nextcloud:apache
          command: ["php", "-f", "/var/www/html/cron.php"]
          resources:
            requests:
              cpu: "256m"   
              memory: "1Gi"  
          securityContext:
            runAsUser: 33
            runAsGroup: 33
          volumeMounts:
          - name: nextcloud-pvc
            mountPath: /var/www/html
        restartPolicy: OnFailure
        volumes:
        - name: nextcloud-pvc
          persistentVolumeClaim:
            claimName: nextcloud-pvc

Step 4: Completing the setup

Once Nextcloud is deployed you can check if the Memory caching has been configured correctly by visiting Administration > Overview > Security & setup warnings

Check for:

  1. Cache warnings: “No memory cache has been configured. To enhance your performance please configure a memcache if available.”
  2. Transactional file: “Transactional file locking is disabled, this might lead to issues with race conditions.”

If these warnings show up you can turn to the Nextcloud configuration / Memory caching documentation

Lastly you can use the Nextcloud Security Scan to see if your installation is up to date and well secured.

Nextcloud Security Scan Results

Changelog:

19-06-2025
Major updates:

  • Changed Nextcloud StatefulSet to Deployment
  • Changed NodePort to ClusterIP for ingres-ngnix
  • Changed nodeSelector from worker-ravenclaw to worker-slytherin
  • Added nextcloud-volume.yaml for local-storage access
  • Added backgroundjob: nextcloud-cron.yaml
  • Added certificate: nextcloud-cert.yaml
  • Added ingress: nextcloud-ingress.yaml

Minor updates:

  • Updated k3s cluster overview, added worker: node4-slytherin
  • Changed nextcloud dashboard image after completing the setup