{"ArticleId":null,"Name":"Running in production: Docker and Kubernetes","Content":"\n\u003Cp\u003EThe 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: \u003Cstrong\u003Econfiguration from the environment\u003C/strong\u003E (no secrets in files), \u003Cstrong\u003Eshared state\u003C/strong\u003E for the few files the application writes, and \u003Cstrong\u003ERedis\u003C/strong\u003E so that every pod\u0027s in-memory cache is cleared when data changes. This article describes all three, as of GrandNode 2.4 (Q4 2026).\u003C/p\u003E\n\n\u003Ch2 id=\u0022connection-string\u0022\u003EThe connection string from an environment variable\u003C/h2\u003E\n\u003Cp\u003EThe installation wizard normally writes the MongoDB connection string into \u003Ccode\u003EApp_Data/Settings.cfg\u003C/code\u003E. 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 \u003Ccode\u003ESettings.cfg\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EConnectionStrings__Mongodb=mongodb://grandnode:\u2022\u2022\u2022@mongo-0.mongo:27017/grandnode?authSource=admin\nConnectionStrings__Provider=0\u003C/code\u003E\u003C/pre\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe name is \u003Ccode\u003EConnectionStrings__Mongodb\u003C/code\u003E - the configuration key \u003Ccode\u003EConnectionStrings:Mongodb\u003C/code\u003E with the colon written as a double underscore, as for every ASP.NET Core setting.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EConnectionStrings__Provider\u003C/code\u003E is optional; \u003Ccode\u003E0\u003C/code\u003E is MongoDB (the default).\u003C/li\u003E\n\u003Cli\u003EIn Kubernetes, keep it in a \u003Cstrong\u003ESecret\u003C/strong\u003E and reference it with \u003Ccode\u003EsecretKeyRef\u003C/code\u003E; never bake it into the image or a ConfigMap. The same applies to API keys and the password pepper (\u003Ccode\u003ESecurity__PasswordHashKey\u003C/code\u003E).\u003C/li\u003E\n\u003Cli\u003EAny setting from \u003Ccode\u003EApp_Data/appsettings.json\u003C/code\u003E can be overridden the same way, for example \u003Ccode\u003ESecurity__UseForwardedHeaders=true\u003C/code\u003E. With \u003Ccode\u003EAzure__AppConfiguration\u003C/code\u003E set, settings can also come from Azure App Configuration.\u003C/li\u003E\n\u003C/ul\u003E\n\n\u003Ch2 id=\u0022state\u0022\u003EWhat the application writes\u003C/h2\u003E\n\u003Cp\u003EEverything else lives in MongoDB. These are the only places on disk the application writes to, and what to do with each:\u003C/p\u003E\n\u003Ctable\u003E\n\u003Cthead\u003E\u003Ctr\u003E\u003Cth\u003EPath\u003C/th\u003E\u003Cth\u003EWhat it holds\u003C/th\u003E\u003Cth\u003EOne container\u003C/th\u003E\u003Cth\u003ESeveral pods\u003C/th\u003E\u003C/tr\u003E\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\u003Ctd\u003E\u003Ccode\u003EApp_Data/Settings.cfg\u003C/code\u003E\u003C/td\u003E\u003Ctd\u003EThe connection string written by the wizard.\u003C/td\u003E\u003Ctd\u003EPersist, or use the environment variable.\u003C/td\u003E\u003Ctd\u003EUse the environment variable.\u003C/td\u003E\u003C/tr\u003E\n\u003Ctr\u003E\u003Ctd\u003E\u003Ccode\u003EApp_Data/InstalledPlugins.cfg\u003C/code\u003E\u003C/td\u003E\u003Ctd\u003EWhich plugins are installed. Without it the store starts with no plugins - no theme, no payment methods.\u003C/td\u003E\u003Ctd\u003EPersist.\u003C/td\u003E\u003Ctd\u003EUse \u003Ccode\u003EExtensions__InstalledPlugins\u003C/code\u003E (comma-separated system names; it overrides the file), or put the file on a shared volume.\u003C/td\u003E\u003C/tr\u003E\n\u003Ctr\u003E\u003Ctd\u003E\u003Ccode\u003EApp_Data/DataProtectionKeys\u003C/code\u003E\u003C/td\u003E\u003Ctd\u003EKeys that protect sign-in cookies and form tokens.\u003C/td\u003E\u003Ctd\u003EPersist, or customers and staff are signed out on every restart.\u003C/td\u003E\u003Ctd\u003EMust be the same for all pods: Redis (\u003Ccode\u003ERedis__PersistKeysToRedis=true\u003C/code\u003E, \u003Ccode\u003ERedis__PersistKeysToRedisUrl\u003C/code\u003E), Azure, or a shared volume set in \u003Ccode\u003ESecurity__KeyPersistenceLocation\u003C/code\u003E. Otherwise a form posted to another pod fails and users are signed out randomly.\u003C/td\u003E\u003C/tr\u003E\n\u003Ctr\u003E\u003Ctd\u003ERuntime web files: custom CSS/JS (\u003Ccode\u003Eassets/custom\u003C/code\u003E), editor uploads (\u003Ccode\u003Eassets/images/uploaded\u003C/code\u003E), thumbnails (\u003Ccode\u003Eassets/images/thumbs\u003C/code\u003E), pictures stored on disk, \u003Ccode\u003Esitemap*.xml\u003C/code\u003E, \u003Ccode\u003Efirebase-messaging-sw.js\u003C/code\u003E\u003C/td\u003E\u003Ctd\u003EFiles the shop writes while it runs.\u003C/td\u003E\u003Ctd\u003ESet \u003Ccode\u003EApplication__MediaPath\u003C/code\u003E to a volume (for example \u003Ccode\u003E/app/media\u003C/code\u003E).\u003C/td\u003E\u003Ctd\u003ESet \u003Ccode\u003EApplication__MediaPath\u003C/code\u003E on every pod to the \u003Cstrong\u003Esame\u003C/strong\u003E 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.\u003C/td\u003E\u003C/tr\u003E\n\u003Ctr\u003E\u003Ctd\u003E\u003Ccode\u003Ewwwroot/assets/files\u003C/code\u003E\u003C/td\u003E\u003Ctd\u003EGenerated PDF files (not covered by \u003Ccode\u003EMediaPath\u003C/code\u003E).\u003C/td\u003E\u003Ctd\u003EPersist if you keep them.\u003C/td\u003E\u003Ctd\u003EShared volume, or accept that a generated file is available only on the pod that created it.\u003C/td\u003E\u003C/tr\u003E\n\u003Ctr\u003E\u003Ctd\u003E\u003Ccode\u003EPlugins/\u003C/code\u003E\u003C/td\u003E\u003Ctd\u003EPlugin binaries.\u003C/td\u003E\u003Ctd\u003EBaked into the image.\u003C/td\u003E\u003Ctd\u003EBaked into the image. Set \u003Ccode\u003EExtensions__DisableUploadExtensions=true\u003C/code\u003E: a plugin uploaded through the admin would land on one pod only.\u003C/td\u003E\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\n\u003Cp\u003EProduct downloads (digital products) are stored in the database, not on disk. \u003Ccode\u003EApplication:MediaPath\u003C/code\u003E is available from GrandNode 2.4; its rules - served ahead of \u003Ccode\u003Ewwwroot\u003C/code\u003E, which paths are refused, how to move an existing installation - are in the \u003Ca href=\u0022/system-appsettings#media-path\u0022\u003Eapplication settings reference\u003C/a\u003E. Without it (older versions), the runtime files live in \u003Ccode\u003Ewwwroot\u003C/code\u003E and each pod has its own copies.\u003C/p\u003E\n\n\u003Cblockquote\u003E\u003Cp\u003E\u003Cstrong\u003EDo not mount a volume over the whole \u003Ccode\u003EApp_Data\u003C/code\u003E in Kubernetes.\u003C/strong\u003E The folder also contains \u003Ccode\u003Eappsettings.json\u003C/code\u003E and \u003Ccode\u003EResources\u003C/code\u003E (the language files, including \u003Ccode\u003EResources/Upgrade/*.xml\u003C/code\u003E 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 \u003Ccode\u003Eappsettings.json\u003C/code\u003E and language files, and new texts are missing. Mount only the subfolders you need, or use environment variables.\u003C/p\u003E\u003C/blockquote\u003E\n\n\u003Ch2 id=\u0022docker\u0022\u003EOne container with Docker\u003C/h2\u003E\n\u003Cp\u003EA production-style single host keeps state in named volumes and the connection string in the environment:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Edocker run -d --name grandnode -p 8080:8080 \\\n  -e ConnectionStrings__Mongodb=\u0022mongodb://grandnode:\u2022\u2022\u2022@mongo:27017/grandnode?authSource=admin\u0022 \\\n  -e Security__UseForwardedHeaders=true \\\n  -e Application__MediaPath=/app/media \\\n  -v grandnode_media:/app/media \\\n  -v grandnode_keys:/app/App_Data/DataProtectionKeys \\\n  grandnode/grandnode2:x.xx\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EThe official image creates \u003Ccode\u003E/app/media\u003C/code\u003E and makes it writable for the application user. After the installation wizard has run, copy the list from \u003Ccode\u003EApp_Data/InstalledPlugins.cfg\u003C/code\u003E into \u003Ccode\u003EExtensions__InstalledPlugins\u003C/code\u003E (or bind-mount that single file), so a recreated container keeps its plugins.\u003C/p\u003E\n\n\u003Ch2 id=\u0022kubernetes\u0022\u003ESeveral pods with Kubernetes\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EDeployment\u003C/strong\u003E with two or more replicas of the same image; configuration from a ConfigMap and a Secret as above.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EProbes\u003C/strong\u003E: liveness \u003Ccode\u003EGET /health/live\u003C/code\u003E, readiness \u003Ccode\u003EGET /health/ready\u003C/code\u003E (container port 8080). Readiness reports the application started; it does not test MongoDB or Redis.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EIngress\u003C/strong\u003E terminating TLS, with \u003Ccode\u003ESecurity__UseForwardedHeaders=true\u003C/code\u003E so links use https. Sticky sessions are not required once data protection keys are shared.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EShared storage\u003C/strong\u003E: one ReadWriteMany volume mounted on every pod at the path in \u003Ccode\u003EApplication__MediaPath\u003C/code\u003E; nothing else needs to be shared when keys are in Redis and plugins are listed in \u003Ccode\u003EExtensions__InstalledPlugins\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERedis\u003C/strong\u003E for cache invalidation and data protection keys - required, see below.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EScheduled tasks\u003C/strong\u003E need no extra setup: every run of a task is claimed atomically in the database by exactly one pod (see \u003Ca href=\u0022/system-scheduled-tasks\u0022\u003EScheduled tasks\u003C/a\u003E).\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUpgrades\u003C/strong\u003E: 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.\u003C/li\u003E\n\u003C/ul\u003E\n\n\u003Ch2 id=\u0022redis\u0022\u003ERedis: clearing every pod\u0027s cache\u003C/h2\u003E\n\u003Cp\u003EGrandNode caches data - settings, categories, menus, prices, permissions - in each process\u0027s 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 (\u003Ccode\u003ECache__DefaultCacheTimeMinutes\u003C/code\u003E, 60 minutes by default). With several pods this shows up as settings that \u0022sometimes\u0022 apply, prices that differ between page loads, and new products that appear only on refresh.\u003C/p\u003E\n\u003Cp\u003EWith 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\u0027s memory. GrandNode does not use Redis as a distributed cache (as of Q4 2026).\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode\u003ERedis__RedisPubSubEnabled=true\nRedis__RedisPubSubConnectionString=redis-master.redis:6379,password=\u2022\u2022\u2022,abortConnect=false\nRedis__RedisPubSubChannel=grandnode-production\nRedis__PersistKeysToRedis=true\nRedis__PersistKeysToRedisUrl=redis-master.redis:6379,password=\u2022\u2022\u2022,defaultDatabase=1\u003C/code\u003E\u003C/pre\u003E\n\u003Cul\u003E\n\u003Cli\u003EAll pods of one store must use the same channel. Use a different channel for each environment (staging, production) that shares a Redis server.\u003C/li\u003E\n\u003Cli\u003EIf 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 \u003Ccode\u003EFailed to subscribe to Redis pub/sub channel\u003C/code\u003E.\u003C/li\u003E\n\u003C/ul\u003E\n\n\u003Ch2 id=\u0022test\u0022\u003ETest that invalidation really works\u003C/h2\u003E\n\u003Cp\u003EDo this once after setting up the cluster and after every change to Redis or the network policy - a misconfigured channel fails silently.\u003C/p\u003E\n\u003Cp\u003EYou can rehearse the whole test on your own machine first: the repository\u0027s .NET Aspire host starts MongoDB, Redis and two replicas of the store - see \u003Ca href=\u0022/developers-aspire-local-testing\u0022\u003ELocal multi-instance testing with .NET Aspire\u003C/a\u003E.\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ECheck each pod\u0027s start-up log for \u003Ccode\u003ESubscribed to Redis pub/sub channel grandnode-production\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EFor the test, raise the log level of the cache bus: \u003Ccode\u003ELogging__LogLevel__Grand.Infrastructure.Caching.Redis=Debug\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EOpen a product on the storefront through two different pods (port-forward to each pod, or call the pods directly) so both have it cached.\u003C/li\u003E\n\u003Cli\u003EIn the admin, change the product\u0027s name or price and save.\u003C/li\u003E\n\u003Cli\u003EThe pod that saved logs \u003Ccode\u003EPublished cache invalidation message ... delivered to N subscriber(s)\u003C/code\u003E, where N should equal the number of pods. Every other pod logs \u003Ccode\u003EReceived cache invalidation message ... from client ...\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EReload 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.\u003C/li\u003E\n\u003Cli\u003ERepeat with gear menu \u2192 \u003Cstrong\u003EClear memory cache\u003C/strong\u003E: every pod should log a \u003Ccode\u003EClearCache\u003C/code\u003E message.\u003C/li\u003E\n\u003Cli\u003ESet the log level back to Information.\u003C/li\u003E\n\u003C/ol\u003E\n\n\u003Ch2 id=\u0022checklist\u0022\u003EChecklist\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003EConnection string and secrets from a Secret, not from files in the image.\u003C/li\u003E\n\u003Cli\u003EInstalled plugins from \u003Ccode\u003EExtensions__InstalledPlugins\u003C/code\u003E or a shared file; plugin upload disabled.\u003C/li\u003E\n\u003Cli\u003EData protection keys in Redis (or Azure / shared volume).\u003C/li\u003E\n\u003Cli\u003ERedis pub/sub enabled, one channel per environment, invalidation tested.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EApplication__MediaPath\u003C/code\u003E on a shared volume (custom CSS/JS, uploads, thumbnails, sitemap); pictures in the database, cloud storage or on that volume.\u003C/li\u003E\n\u003Cli\u003EForwarded headers on, store URL set to the public https address.\u003C/li\u003E\n\u003Cli\u003EProbes on \u003Ccode\u003E/health/live\u003C/code\u003E and \u003Ccode\u003E/health/ready\u003C/code\u003E.\u003C/li\u003E\n\u003C/ul\u003E\n\n\u003Ch2 id=\u0022related\u0022\u003ERelated\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/getting-started-installing\u0022\u003EInstalling GrandNode\u003C/a\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/system-appsettings\u0022\u003EApplication settings (appsettings.json)\u003C/a\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/getting-started-upgrading\u0022\u003EUpgrading GrandNode\u003C/a\u003E\u003C/li\u003E\n\u003C/ul\u003E\n","ParentCategoryId":"6abdec6c83d2816248229ea1","SeName":"getting-started-docker-kubernetes","MetaKeywords":null,"MetaDescription":"GrandNode in Docker and Kubernetes: connection string as an env variable, what to persist, Redis cache invalidation across pods and how to test it.","MetaTitle":null,"AllowComments":false,"Captcha":{"ReCaptchaChallengeField":null,"ReCaptchaResponseField":null,"ReCaptchaResponseValue":null,"ReCaptchaResponse":null},"RelatedArticles":[],"CategoryBreadcrumb":[{"Name":"Getting started","Description":null,"IsCurrent":false,"Children":null,"Parent":null,"SeName":"docs-getting-started","Id":"6abdec6c83d2816248229ea1","UserFields":[]}],"AddNewComment":{"CommentText":null,"DisplayCaptcha":false,"Id":null,"UserFields":[]},"Comments":[],"Id":"6abe3e2760b106d8a8bb1558","UserFields":[]}