Introduction

This guide walks you through running a Nextcloud server on Atlas Cloud, a self-hosted file sync, calendar, contacts, and collaboration platform. The deployment uses Atlas’s managed Kubernetes (CKS), the built-in CloudStack CSI driver for persistent volumes, and Let’s Encrypt via cert-manager for TLS. Optionally, primary file storage can live in an Atlas S3-compatible object-storage bucket.

The end result is https://nextcloud.example.com serving Nextcloud over a Let’s Encrypt certificate, fronted by an ingress-nginx LoadBalancer.

Prerequisites

  • An Atlas Cloud account. Sign up here.
  • An SSH keypair registered in Atlas (see SSH Key Pairs).
  • kubectl installed locally (see the Kubernetes install guide).
  • A domain you control DNS for (the example uses nextcloud.example.com).
  • About 30 GiB of disk for the postgres + Nextcloud /var/www/html, plus however much your file data needs (Custom disk offering, sized per-PVC).

Step 1: Create the Kubernetes cluster

Follow Deploying Kubernetes on Atlas up to the Connect with kubectl step. Recommended starting shape for a single-tenant Nextcloud:

  • Service offering: Atlas.a5 (2 vCPU / 8 GiB), comfortable headroom for php-fpm, postgres, redis, and ingress-nginx on one worker. (Atlas.a4 at 1 vCPU fails CKS validation, which requires at least 2 vCPU per node.)
  • Cluster size: 1 worker.
  • Control nodes: 1.
  • Network: leave blank. Atlas creates an isolated network for you.
  • CSI: tick Enable CSI. The CloudStack CSI driver provisions block volumes on demand for your PVCs.

After the cluster reaches Running, download the kubeconfig and confirm:

export KUBECONFIG=~/Downloads/kube.conf
kubectl get nodes

Step 2: Allow egress and confirm the CSI driver

The isolated guest network blocks outbound traffic by default until you add egress rules. Without them, image pulls, DNS, and package installs all fail. Add the standard egress allowances to 0.0.0.0/0 under Network → Egress rules in the Atlas console, or with cmk:

  • TCP 80 and TCP 443 (image pulls, ACME, package mirrors)
  • UDP 53 and TCP 53 (DNS)

Confirm the CSI driver shipped with the cluster is registered on each node:

kubectl get csinode -o jsonpath='{range .items[*]}{.metadata.name}: drivers={.spec.drivers}{"\n"}{end}'
# Expect each node to have driver "csi.cloudstack.apache.org" with a topology key.

If a node shows no driver, check that Enable CSI was ticked at cluster creation.

Default StorageClass

# 05-storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: cloudstack-custom
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: csi.cloudstack.apache.org
parameters:
  # The "Custom" disk offering; size is set per-PVC.
  csi.cloudstack.apache.org/disk-offering-id: "<your-zone's-Custom-offering-id>"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true

Find the disk offering ID with the Atlas Console under Compute → Disk offerings, or via cmk: cmk list diskofferings.

kubectl apply -f 05-storageclass.yaml

Step 3: Ingress + cert-manager

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/cloud/deploy.yaml
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
 
# Wait for ingress-nginx to grab a public IP from Atlas's CCM:
kubectl -n ingress-nginx get svc ingress-nginx-controller -w

Note the EXTERNAL-IP, then point your DNS A record (e.g. nextcloud.example.com) at that address before the next step. Do not proxy through Cloudflare during ACME issuance, or use DNS-01 instead of HTTP-01.

# 55-clusterissuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
kubectl apply -f 55-clusterissuer.yaml

Step 4: (Optional) Provision an Atlas S3 bucket

If you want primary file storage in object storage instead of a block-volume PVC, create a bucket via the Atlas console (Storage → Buckets) or with cmk:

cmk create bucket name=nextcloud-prod \
  objectstorageid=<your atlas-storage pool id> \
  quota=512   # GiB

The bucket page (or cmk list buckets) gives you an accesskey and usersecretkey, both S3-compatible. Stash them in a Kubernetes Secret as shown in the next step.

Note: this guide deploys Nextcloud with filesystem-primary storage (data on a PVC). S3-primary works at install time on a fresh Nextcloud, but converting a filesystem-primary install to S3-primary later requires re-uploading every file with its oc_filecache.fileid in the S3 key. If you’re starting fresh and want S3, set OBJECTSTORE_S3_* env vars at install; otherwise leave it filesystem-primary.

Step 5: Apply the Nextcloud workload

# 00-namespace.yaml
apiVersion: v1
kind: Namespace
metadata: { name: nextcloud }

Secrets (created out-of-band; not committed to git)

kubectl -n nextcloud create secret generic nextcloud-postgres \
  --from-literal=POSTGRES_DB=nextcloud \
  --from-literal=POSTGRES_USER=nextcloud \
  --from-literal=POSTGRES_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | head -c 28)" \
  --from-literal=POSTGRES_HOST=nextcloud-postgres
 
kubectl -n nextcloud create secret generic nextcloud-admin \
  --from-literal=NEXTCLOUD_ADMIN_USER=admin \
  --from-literal=NEXTCLOUD_ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | head -c 28)"

Postgres + Redis

# 20-postgres.yaml
apiVersion: v1
kind: Service
metadata: { name: nextcloud-postgres, namespace: nextcloud }
spec:
  selector: { app: nextcloud-postgres }
  ports: [ { port: 5432, targetPort: 5432, name: postgres } ]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: nextcloud-postgres, namespace: nextcloud }
spec:
  serviceName: nextcloud-postgres
  replicas: 1
  selector: { matchLabels: { app: nextcloud-postgres } }
  template:
    metadata: { labels: { app: nextcloud-postgres } }
    spec:
      containers:
        - name: postgres
          image: postgres:17-alpine
          envFrom: [ { secretRef: { name: nextcloud-postgres } } ]
          ports: [ { containerPort: 5432 } ]
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data, subPath: pgdata }
          readinessProbe: { exec: { command: [pg_isready, -U, "$(POSTGRES_USER)"] }, periodSeconds: 5 }
          resources:
            requests: { cpu: 100m, memory: 256Mi }
            limits:   { cpu: 1,   memory: 1Gi }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        resources: { requests: { storage: 10Gi } }
# 30-redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: nextcloud-redis, namespace: nextcloud }
spec:
  replicas: 1
  selector: { matchLabels: { app: nextcloud-redis } }
  template:
    metadata: { labels: { app: nextcloud-redis } }
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          args: [--maxmemory, 128mb, --maxmemory-policy, allkeys-lru]
          ports: [ { containerPort: 6379 } ]
          resources:
            requests: { cpu: 50m,  memory: 64Mi }
            limits:   { cpu: 500m, memory: 192Mi }
---
apiVersion: v1
kind: Service
metadata: { name: nextcloud-redis, namespace: nextcloud }
spec:
  selector: { app: nextcloud-redis }
  ports: [ { port: 6379, targetPort: 6379 } ]

Nextcloud

# 40-nextcloud.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: nextcloud-data, namespace: nextcloud }
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 100Gi   # holds /var/www/html (code + config + data subdir)
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: nextcloud, namespace: nextcloud }
spec:
  replicas: 1
  strategy: { type: Recreate }
  selector: { matchLabels: { app: nextcloud } }
  template:
    metadata: { labels: { app: nextcloud } }
    spec:
      containers:
        - name: nextcloud
          image: nextcloud:fpm-alpine   # pin to a specific supported major in production
          envFrom:
            - { secretRef: { name: nextcloud-admin } }
            - { secretRef: { name: nextcloud-postgres } }
          env:
            - { name: POSTGRES_HOST, value: nextcloud-postgres }
            - { name: REDIS_HOST,    value: nextcloud-redis }
            - { name: NEXTCLOUD_DATA_DIR,        value: /var/www/html/data }
            - { name: NEXTCLOUD_TRUSTED_DOMAINS, value: "nextcloud.example.com" }
            - { name: TRUSTED_PROXIES,           value: "<your-cluster-pod-cidr>" }   # the cluster Pod CIDR; see note below
            - { name: PHP_UPLOAD_LIMIT, value: "16G" }
            - { name: PHP_MEMORY_LIMIT, value: "512M" }
          ports: [ { containerPort: 9000, name: fpm } ]
          volumeMounts:
            - { name: nextcloud-storage, mountPath: /var/www/html }
          readinessProbe: { tcpSocket: { port: 9000 }, initialDelaySeconds: 30, periodSeconds: 10 }
          resources:
            requests: { cpu: 250m, memory: 512Mi }
            limits:   { cpu: 2,    memory: 2Gi }
        - name: nginx
          image: nginx:1.27-alpine
          ports: [ { containerPort: 8080, name: http } ]
          volumeMounts:
            - { name: nextcloud-storage, mountPath: /var/www/html, readOnly: true }
            - { name: nginx-conf,        mountPath: /etc/nginx/conf.d }
          resources:
            requests: { cpu: 50m,  memory: 64Mi }
            limits:   { cpu: 500m, memory: 256Mi }
      volumes:
        - { name: nextcloud-storage, persistentVolumeClaim: { claimName: nextcloud-data } }
        - { name: nginx-conf,        configMap: { name: nextcloud-nginx-conf } }
---
apiVersion: v1
kind: Service
metadata: { name: nextcloud, namespace: nextcloud }
spec:
  selector: { app: nextcloud }
  ports: [ { port: 80, targetPort: 8080, name: http } ]
---
# nginx config trimmed for readability. Copy the official Nextcloud
# fpm-alpine + nginx config from
# https://github.com/nextcloud/docker/blob/master/.examples/docker-compose/insecure/mariadb/fpm/web/nginx.conf
# and load it into the configmap below.
apiVersion: v1
kind: ConfigMap
metadata: { name: nextcloud-nginx-conf, namespace: nextcloud }
data:
  default.conf: |
    upstream php-handler { server 127.0.0.1:9000; }
    server {
      listen 8080;
      client_max_body_size 16G;
      root /var/www/html;
      index index.php index.html /index.php$request_uri;
      # ... see Nextcloud's official nginx.conf for the full ruleset
    }

Note: TRUSTED_PROXIES should list only the subnet of the proxy that terminates client connections to Nextcloud. Here that is the ingress-nginx controller pod, whose address comes from the cluster Pod CIDR. Set the value to your cluster’s Pod CIDR: CKS uses Calico, so it is the pod pool Calico allocates from (see the Calico IP pool documentation). Find it with kubectl get ippool -o jsonpath='{.items[*].spec.cidr}', or use the custom Pod CIDR you set at cluster creation. Trust only that subnet: any host inside a wider trusted range can spoof X-Forwarded-For to defeat brute-force throttling and poison audit logs, so do not widen it to the node or the broader private address space.

Ingress + LE cert

# 60-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nextcloud
  namespace: nextcloud
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: "16g"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
  ingressClassName: nginx
  tls:
    - hosts: [nextcloud.example.com]
      secretName: nextcloud-example-com-tls
  rules:
    - host: nextcloud.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nextcloud
                port: { number: 80 }

Apply everything in order:

kubectl apply -f 00-namespace.yaml \
              -f 05-storageclass.yaml \
              -f 20-postgres.yaml -f 30-redis.yaml -f 40-nextcloud.yaml \
              -f 55-clusterissuer.yaml -f 60-ingress.yaml
kubectl -n nextcloud get pods -w

On first run, the nextcloud pod generates /var/www/html/config/config.php, runs DB migrations, and writes the admin user. Once the pod is 2/2 Running and the DNS A record resolves to the ingress EXTERNAL-IP, cert-manager completes the HTTP-01 challenge and the certificate becomes Ready:

kubectl -n nextcloud get certificate

Visit https://nextcloud.example.com/ and log in with the admin credentials you put in the nextcloud-admin Secret.

Step 6: Post-install hardening

NC=$(kubectl -n nextcloud get pods -l app=nextcloud -o name | head -1)
 
# Repair indices, columns, primary keys (one-shot, idempotent)
kubectl -n nextcloud exec -c nextcloud "$NC" -- su -s /bin/sh www-data -c '
  php /var/www/html/occ db:add-missing-indices --no-interaction
  php /var/www/html/occ db:add-missing-columns --no-interaction
  php /var/www/html/occ db:add-missing-primary-keys --no-interaction
'
 
# Optional: enforce 2FA for all users
kubectl -n nextcloud exec -c nextcloud "$NC" -- su -s /bin/sh www-data -c '
  php /var/www/html/occ config:system:set twofactor_enforced --value=true
'

Pitfalls and tips

  • PVC Pending for minutes: volume create plus attach can stall if multiple PVCs are provisioned concurrently to the same VM. Symptom: one volume goes Ready/attached, the others stay Allocated/Creating. Mitigation: keep your Pod’s volumeClaimTemplate and PersistentVolumeClaim count down, ideally one PVC per Pod, attached sequentially. The example above uses one PVC for postgres and one for Nextcloud.
  • Cert-manager challenge stays pending: confirm the DNS A record points at the ingress EXTERNAL-IP and is resolvable from inside the cluster. kubectl exec into a debug pod and dig the hostname; if it fails to resolve, the HTTP-01 challenge cannot complete. Check kubectl describe challenge for the specific error.
  • Image pulls timing out: see Step 2, confirm the egress rules allowing TCP 80/443 and DNS are present.
  • Cookies aren’t kept on plain HTTP: Nextcloud sets several cookies with Secure; and __Host- prefix. Test only over HTTPS (or hit the ingress with --resolve from curl).
  • Memory-limited workers: php-fpm is the long pole. Bump PHP_MEMORY_LIMIT to 1024M+ if you have lots of concurrent users or run heavy apps (Memories, Recognize).
  • Backups: take a cmk createSnapshot against the data PVC’s volume on a schedule (cmk list volumes), and run pg_dump from a sidecar or CronJob to the same bucket.