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).
kubectlinstalled 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 (Customdisk 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.a4at 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 nodesStep 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: trueFind the disk offering ID with the Atlas Console under Compute → Disk offerings, or via cmk: cmk list diskofferings.
kubectl apply -f 05-storageclass.yamlStep 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 -wNote 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: nginxkubectl apply -f 55-clusterissuer.yamlStep 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 # GiBThe 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.fileidin the S3 key. If you’re starting fresh and want S3, setOBJECTSTORE_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_PROXIESshould 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 withkubectl 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 spoofX-Forwarded-Forto 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 -wOn 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 certificateVisit 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
Pendingfor minutes: volume create plus attach can stall if multiple PVCs are provisioned concurrently to the same VM. Symptom: one volume goesReady/attached, the others stayAllocated/Creating. Mitigation: keep your Pod’svolumeClaimTemplateandPersistentVolumeClaimcount 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 execinto a debug pod anddigthe hostname; if it fails to resolve, the HTTP-01 challenge cannot complete. Checkkubectl describe challengefor 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--resolvefromcurl). - Memory-limited workers: php-fpm is the long pole. Bump
PHP_MEMORY_LIMITto 1024M+ if you have lots of concurrent users or run heavy apps (Memories, Recognize). - Backups: take a
cmk createSnapshotagainst the data PVC’s volume on a schedule (cmk list volumes), and runpg_dumpfrom a sidecar or CronJob to the same bucket.