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 keyConnectionStrings:Mongodbwith the colon written as a double underscore, as for every ASP.NET Core setting. ConnectionStrings__Provideris optional;0is 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.jsoncan be overridden the same way, for exampleSecurity__UseForwardedHeaders=true. WithAzure__AppConfigurationset, 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. 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_Datain Kubernetes. The folder also containsappsettings.jsonandResources(the language files, includingResources/Upgrade/*.xmlthat 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 newappsettings.jsonand 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, readinessGET /health/ready(container port 8080). Readiness reports the application started; it does not test MongoDB or Redis. - Ingress terminating TLS, with
Security__UseForwardedHeaders=trueso 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 inExtensions__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.
- Check each pod's start-up log for
Subscribed to Redis pub/sub channel grandnode-production. - For the test, raise the log level of the cache bus:
Logging__LogLevel__Grand.Infrastructure.Caching.Redis=Debug. - 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.
- In the admin, change the product's name or price and save.
- 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 logsReceived cache invalidation message ... from client .... - 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.
- Repeat with gear menu → Clear memory cache: every pod should log a
ClearCachemessage. - 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__InstalledPluginsor 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__MediaPathon 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/liveand/health/ready.
Related
Question not answered here? Ask the community on GitHub Discussions ↗