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 object-storage bucket (S3-compatible, Ceph-backed).

The end result is https://nextcloud.example.com serving Nextcloud over a real LE-issued 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.
  • 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: Wire up egress and the CSI driver

The CKS Kubernetes Service network offering ships with egressdefaultpolicy: true, meaning the network defaults to ALLOW egress and any rules you add act as DENIES. Leave the network’s egress firewall empty — adding rules will block your workers from pulling images. (You can verify the policy under Network → Egress rules in the Atlas console; if the list is empty you’re good.)

The cluster comes with the CloudStack CSI controller pre-installed, but you need to point its API URL at a publicly-routable Atlas mgmt endpoint and add a DNS override so it can resolve.

Patch the CSI/CCM mgmt URL

kubectl -n kube-system get secret cloudstack-secret \
  -o jsonpath='{.data.cloud-config}' | base64 -d \
  | sed -E 's|^api-url *=.*|api-url = https://sky.runatlas.is/client/api|' \
  | base64 -w0 \
  | xargs -I{} kubectl -n kube-system patch secret cloudstack-secret \
    --type=merge -p '{"data":{"cloud-config":"{}"}}'

Override CoreDNS so sky.runatlas.is resolves correctly inside the cluster

Public DNS for sky.runatlas.is round-robins between IPs only some of which are routable from inside isolated networks. Pin it to the public LB by patching CoreDNS:

kubectl -n kube-system get cm coredns -o yaml > /tmp/coredns.yaml
# Edit /tmp/coredns.yaml and add a hosts block, plus switch the forwarder
# from /etc/resolv.conf to public resolvers (the network VR caches NXDOMAIN
# aggressively, which breaks fresh hostnames during cert-manager challenges):
cat <<'EOF' | kubectl -n kube-system create cm coredns --from-file=Corefile=/dev/stdin --dry-run=client -o yaml | kubectl apply -f -
.:53 {
    errors
    health { lameduck 5s }
    ready
    hosts {
       213.181.96.241 sky.runatlas.is
       fallthrough
    }
    kubernetes cluster.local in-addr.arpa ip6.arpa {
       pods insecure
       fallthrough in-addr.arpa ip6.arpa
       ttl 30
    }
    prometheus :9153
    forward . 8.8.8.8 1.1.1.1 { max_concurrent 1000 }
    cache 30 { disable success cluster.local; disable denial cluster.local }
    loop
    reload
    loadbalance
}
EOF
kubectl -n kube-system rollout restart deploy/coredns
kubectl -n kube-system rollout restart deploy/cloudstack-csi-controller daemonset/cloudstack-csi-node deploy/cloud-controller-manager

After the rollout, confirm the CSI driver is registered with the right topology:

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.

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/latest/download/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 — 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 Ceph 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 are 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:33-fpm-alpine
          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: "10.0.0.0/8 192.168.0.0/16" }
            - { 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
    }

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

The nextcloud pod takes ~1 minute to first-run install (it generates /var/www/html/config/config.php, runs DB migrations, and writes the admin user). Once the pod is 2/2 Running, your cert should issue within ~30 s of the DNS A record being correct:

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: CloudStack’s volume create + attach can deadlock 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: the network’s Virtual Router caches NXDOMAIN aggressively. After adding a fresh DNS record, kubectl exec into a debug pod and dig the hostname; if the cluster pod still NXDOMAINs, it’s the cached negative response. The CoreDNS forwarder change above (forward to 8.8.8.8/1.1.1.1 instead of /etc/resolv.conf) bypasses the VR cache.
  • Image pulls timing out: see Step 2 — confirm the network’s egress firewall list is empty.
  • 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.