# Running in production: Docker and Kubernetes

Getting started

The GrandNode image is a stateless .NET application plus a few folders it writes to. Running one container on one host is simple; running several replicas in Kubernetes needs three things: **configuration from the environment** (no secrets in files), **shared state** for the few files the application writes, and **Redis** so that every pod's in-memory cache is cleared when data changes. This article describes all three, as of GrandNode 2.4 (Q4 2026).

### The connection string from an environment variable

The installation wizard normally writes the MongoDB connection string into `App_Data/Settings.cfg`. In containers, pass it as an environment variable instead - it takes precedence over the file, and the wizard then does not ask for the database and does not write `Settings.cfg`:

```
ConnectionStrings__Mongodb=mongodb://grandnode:•••@mongo-0.mongo:27017/grandnode?authSource=admin
ConnectionStrings__Provider=0
```

- The name is `ConnectionStrings__Mongodb` - the configuration key `ConnectionStrings:Mongodb` with the colon written as a double underscore, as for every ASP.NET Core setting.
- `ConnectionStrings__Provider` is optional; `0` is MongoDB (the default).
- In Kubernetes, keep it in a **Secret** and reference it with `secretKeyRef`; never bake it into the image or a ConfigMap. The same applies to API keys and the password pepper (`Security__PasswordHashKey`).
- Any setting from `App_Data/appsettings.json` can be overridden the same way, for example `Security__UseForwardedHeaders=true`. With `Azure__AppConfiguration` set, settings can also come from Azure App Configuration.

### What the application writes

Everything else lives in MongoDB. These are the only places on disk the application writes to, and what to do with each:

| Path | What it holds | One container | Several pods |
|---|---|---|---|
| `App_Data/Settings.cfg` | The connection string written by the wizard. | Persist, or use the environment variable. | Use the environment variable. |
| `App_Data/InstalledPlugins.cfg` | Which plugins are installed. Without it the store starts with no plugins - no theme, no payment methods. | Persist. | Use `Extensions__InstalledPlugins` (comma-separated system names; it overrides the file), or put the file on a shared volume. |
| `App_Data/DataProtectionKeys` | Keys that protect sign-in cookies and form tokens. | Persist, or customers and staff are signed out on every restart. | Must be the same for all pods: Redis (`Redis__PersistKeysToRedis=true`, `Redis__PersistKeysToRedisUrl`), Azure, or a shared volume set in `Security__KeyPersistenceLocation`. Otherwise a form posted to another pod fails and users are signed out randomly. |
| Runtime web files: custom CSS/JS (`assets/custom`), editor uploads (`assets/images/uploaded`), thumbnails (`assets/images/thumbs`), pictures stored on disk, `sitemap*.xml`, `firebase-messaging-sw.js` | Files the shop writes while it runs. | Set `Application__MediaPath` to a volume (for example `/app/media`). | Set `Application__MediaPath` on every pod to the **same** ReadWriteMany volume (Azure Files, EFS, NFS). All pods then serve the same sitemap, custom CSS and uploads. Pictures can also stay in the database (the default) or go to Azure Blob Storage / Amazon S3. |
| `wwwroot/assets/files` | Generated PDF files (not covered by `MediaPath`). | Persist if you keep them. | Shared volume, or accept that a generated file is available only on the pod that created it. |
| `Plugins/` | Plugin binaries. | Baked into the image. | Baked into the image. Set `Extensions__DisableUploadExtensions=true`: a plugin uploaded through the admin would land on one pod only. |

Product downloads (digital products) are stored in the database, not on disk. `Application:MediaPath` is available from GrandNode 2.4; its rules - served ahead of `wwwroot`, which paths are refused, how to move an existing installation - are in the [application settings reference](https://grandnode.com/system-appsettings#media-path). Without it (older versions), the runtime files live in `wwwroot` and each pod has its own copies.

> **Do not mount a volume over the whole `App_Data` in Kubernetes.** The folder also contains `appsettings.json` and `Resources` (the language files, including `Resources/Upgrade/*.xml` that upgrade migrations import). An empty PersistentVolumeClaim hides them and the application does not start. Docker fills a new named volume from the image, so it works there at first - but after an upgrade the old volume still shadows the new `appsettings.json` and language files, and new texts are missing. Mount only the subfolders you need, or use environment variables.

### One container with Docker

A production-style single host keeps state in named volumes and the connection string in the environment:

```
docker run -d --name grandnode -p 8080:8080 \
  -e ConnectionStrings__Mongodb="mongodb://grandnode:•••@mongo:27017/grandnode?authSource=admin" \
  -e Security__UseForwardedHeaders=true \
  -e Application__MediaPath=/app/media \
  -v grandnode_media:/app/media \
  -v grandnode_keys:/app/App_Data/DataProtectionKeys \
  grandnode/grandnode2:x.xx
```

The official image creates `/app/media` and makes it writable for the application user. After the installation wizard has run, copy the list from `App_Data/InstalledPlugins.cfg` into `Extensions__InstalledPlugins` (or bind-mount that single file), so a recreated container keeps its plugins.

### Several pods with Kubernetes

- **Deployment** with two or more replicas of the same image; configuration from a ConfigMap and a Secret as above.
- **Probes**: liveness `GET /health/live`, readiness `GET /health/ready` (container port 8080). Readiness reports the application started; it does not test MongoDB or Redis.
- **Ingress** terminating TLS, with `Security__UseForwardedHeaders=true` so links use https. Sticky sessions are not required once data protection keys are shared.
- **Shared storage**: one ReadWriteMany volume mounted on every pod at the path in `Application__MediaPath`; nothing else needs to be shared when keys are in Redis and plugins are listed in `Extensions__InstalledPlugins`.
- **Redis** for cache invalidation and data protection keys - required, see below.
- **Scheduled tasks** need no extra setup: every run of a task is claimed atomically in the database by exactly one pod (see [Scheduled tasks](https://grandnode.com/system-scheduled-tasks)).
- **Upgrades**: migrations run at start-up. Roll out one pod first (or scale to one), let it finish the upgrade, then scale up; back up the database before.

### Redis: clearing every pod's cache

GrandNode caches data - settings, categories, menus, prices, permissions - in each process's own memory. When an administrator saves something, the pod that handled the request clears its cache. The other pods do not know about it and keep serving the old data until it expires (`Cache__DefaultCacheTimeMinutes`, 60 minutes by default). With several pods this shows up as settings that "sometimes" apply, prices that differ between page loads, and new products that appear only on refresh.

With Redis pub/sub enabled, every cache removal is also published on a Redis channel; all other pods receive it and remove the same keys from their memory. Redis carries only these invalidation messages - the cached data itself stays in each pod's memory. GrandNode does not use Redis as a distributed cache (as of Q4 2026).

```
Redis__RedisPubSubEnabled=true
Redis__RedisPubSubConnectionString=redis-master.redis:6379,password=•••,abortConnect=false
Redis__RedisPubSubChannel=grandnode-production
Redis__PersistKeysToRedis=true
Redis__PersistKeysToRedisUrl=redis-master.redis:6379,password=•••,defaultDatabase=1
```

- All pods of one store must use the same channel. Use a different channel for each environment (staging, production) that shares a Redis server.
- If Redis is unavailable at start-up, the pod starts anyway and keeps retrying the subscription in the background; while it is disconnected, its cache is not cleared by other pods. Monitor the log for `Failed to subscribe to Redis pub/sub channel`.

### Test that invalidation really works

Do this once after setting up the cluster and after every change to Redis or the network policy - a misconfigured channel fails silently.

You can rehearse the whole test on your own machine first: the repository's .NET Aspire host starts MongoDB, Redis and two replicas of the store - see [Local multi-instance testing with .NET Aspire](https://grandnode.com/developers-aspire-local-testing).

1. Check each pod's start-up log for `Subscribed to Redis pub/sub channel grandnode-production`.
2. For the test, raise the log level of the cache bus: `Logging__LogLevel__Grand.Infrastructure.Caching.Redis=Debug`.
3. Open a product on the storefront through two different pods (port-forward to each pod, or call the pods directly) so both have it cached.
4. In the admin, change the product's name or price and save.
5. The pod that saved logs `Published cache invalidation message ... delivered to N subscriber(s)`, where N should equal the number of pods. Every other pod logs `Received cache invalidation message ... from client ...`.
6. Reload the product on the other pod: it must show the new value immediately. If it shows the old one until the cache expires, invalidation does not work.
7. Repeat with gear menu → **Clear memory cache**: every pod should log a `ClearCache` message.
8. Set the log level back to Information.

### Checklist

- Connection string and secrets from a Secret, not from files in the image.
- Installed plugins from `Extensions__InstalledPlugins` or a shared file; plugin upload disabled.
- Data protection keys in Redis (or Azure / shared volume).
- Redis pub/sub enabled, one channel per environment, invalidation tested.
- `Application__MediaPath` on a shared volume (custom CSS/JS, uploads, thumbnails, sitemap); pictures in the database, cloud storage or on that volume.
- Forwarded headers on, store URL set to the public https address.
- Probes on `/health/live` and `/health/ready`.

### Related

- [Installing GrandNode](https://grandnode.com/getting-started-installing)
- [Application settings (appsettings.json)](https://grandnode.com/system-appsettings)
- [Upgrading GrandNode](https://grandnode.com/getting-started-upgrading)

- [Web page](https://grandnode.com/getting-started-docker-kubernetes)
