Running in production: Docker and Kubernetes

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:

PathWhat it holdsOne containerSeveral pods
App_Data/Settings.cfgThe connection string written by the wizard.Persist, or use the environment variable.Use the environment variable.
App_Data/InstalledPlugins.cfgWhich 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/DataProtectionKeysKeys 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.jsFiles 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/filesGenerated 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. 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).
  • 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.

  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.

Question not answered here? Ask the community on GitHub Discussions ↗