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.
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. - 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: 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-managerAfter 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: 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/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 -wNote 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: nginxkubectl apply -f 55-clusterissuer.yamlStep 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 # GiBThe 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.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: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 -wThe 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 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: CloudStack’s volume create + attach can deadlock 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: the network’s Virtual Router caches NXDOMAIN aggressively. After adding a fresh DNS record,kubectl execinto a debug pod anddigthe hostname; if the cluster pod still NXDOMAINs, it’s the cached negative response. The CoreDNS forwarder change above (forward to8.8.8.8/1.1.1.1instead 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--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.