[{"data":1,"prerenderedAt":2916},["ShallowReactive",2],{"navigation-en":3,"content-urls":13,"pagesContext-en-2":224,"pagesContext-en-3":251,"authorsContext-en-jeroenbach":271,"postsContext-en-4-undefined":288},[4,7,10],{"label":5,"to":6},"Blog","\u002Fposts",{"label":8,"to":9},"Portfolio","\u002Fcontent\u002F20-portfolio",{"label":11,"to":12},"About","\u002Fcontent\u002F30-about",{"page":14,"blogPost":101,"blog":223},{"1":15,"10":37,"20":51,"30":65,"404":82},{"en":16,"fr":25,"nl":28,"de":31,"es":34},{"title":17,"path":18,"stem":19,"contentId":20,"url":21,"dateModified":22,"locale":23,"type":24},"Home","\u002Fcontent\u002Fhome","content\u002F1.home",1,"","2025-11-02T18:00:00","en","page",{"title":17,"path":18,"stem":19,"contentId":20,"url":26,"dateModified":22,"locale":27,"type":24},"\u002Ffr","fr",{"title":17,"path":18,"stem":19,"contentId":20,"url":29,"dateModified":22,"locale":30,"type":24},"\u002Fnl","nl",{"title":17,"path":18,"stem":19,"contentId":20,"url":32,"dateModified":22,"locale":33,"type":24},"\u002Fde","de",{"title":17,"path":18,"stem":19,"contentId":20,"url":35,"dateModified":22,"locale":36,"type":24},"\u002Fes","es",{"en":38,"fr":43,"nl":45,"de":47,"es":49},{"title":5,"path":39,"stem":40,"contentId":41,"url":6,"dateModified":42,"locale":23,"type":24},"\u002Fcontent\u002Fposts","content\u002F10.posts",10,"2025-10-26T18:00:00",{"title":5,"path":39,"stem":40,"contentId":41,"url":44,"dateModified":42,"locale":27,"type":24},"\u002Ffr\u002Farticles",{"title":5,"path":39,"stem":40,"contentId":41,"url":46,"dateModified":42,"locale":30,"type":24},"\u002Fnl\u002Fposts",{"title":5,"path":39,"stem":40,"contentId":41,"url":48,"dateModified":42,"locale":33,"type":24},"\u002Fde\u002Fposts",{"title":5,"path":39,"stem":40,"contentId":41,"url":50,"dateModified":42,"locale":36,"type":24},"\u002Fes\u002Farticulos",{"en":52,"fr":56,"nl":58,"de":60,"es":62},{"title":8,"path":53,"stem":54,"contentId":55,"url":9,"dateModified":42,"locale":23,"type":24},"\u002Fcontent\u002Fportfolio","content\u002F20.portfolio",20,{"title":8,"path":53,"stem":54,"contentId":55,"url":57,"dateModified":42,"locale":27,"type":24},"\u002Ffr\u002Fcontent\u002F20-portfolio",{"title":8,"path":53,"stem":54,"contentId":55,"url":59,"dateModified":42,"locale":30,"type":24},"\u002Fnl\u002Fcontent\u002F20-portfolio",{"title":8,"path":53,"stem":54,"contentId":55,"url":61,"dateModified":42,"locale":33,"type":24},"\u002Fde\u002Fcontent\u002F20-portfolio",{"title":63,"path":53,"stem":54,"contentId":55,"url":64,"dateModified":42,"locale":36,"type":24},"Portafolio","\u002Fes\u002Fcontent\u002F20-portafolio",{"en":66,"fr":70,"nl":73,"de":76,"es":79},{"title":11,"path":67,"stem":68,"contentId":69,"url":12,"dateModified":42,"locale":23,"type":24},"\u002Fcontent\u002Fabout","content\u002F30.about",30,{"title":71,"path":67,"stem":68,"contentId":69,"url":72,"dateModified":42,"locale":27,"type":24},"À propos","\u002Ffr\u002Fcontent\u002F30-a-propos",{"title":74,"path":67,"stem":68,"contentId":69,"url":75,"dateModified":42,"locale":30,"type":24},"Over","\u002Fnl\u002Fcontent\u002F30-over",{"title":77,"path":67,"stem":68,"contentId":69,"url":78,"dateModified":42,"locale":33,"type":24},"Über mich","\u002Fde\u002Fcontent\u002F30-uber-mich",{"title":80,"path":67,"stem":68,"contentId":69,"url":81,"dateModified":42,"locale":36,"type":24},"Acerca de","\u002Fes\u002Fcontent\u002F30-acerca-de",{"en":83,"fr":89,"nl":92,"de":95,"es":98},{"title":84,"path":85,"stem":86,"contentId":87,"url":88,"dateModified":22,"locale":23,"type":24},"Page not found","\u002Fcontent\u002Fpage-not-found","content\u002F404.page-not-found",404,"\u002Fcontent\u002F404-page-not-found",{"title":90,"path":85,"stem":86,"contentId":87,"url":91,"dateModified":22,"locale":27,"type":24},"Page non trouvée","\u002Ffr\u002Fcontent\u002F404-page-non-trouvee",{"title":93,"path":85,"stem":86,"contentId":87,"url":94,"dateModified":22,"locale":30,"type":24},"Pagina niet gevonden","\u002Fnl\u002Fcontent\u002F404-pagina-niet-gevonden",{"title":96,"path":85,"stem":86,"contentId":87,"url":97,"dateModified":22,"locale":33,"type":24},"Seite nicht gefunden","\u002Fde\u002Fcontent\u002F404-seite-nicht-gefunden",{"title":99,"path":85,"stem":86,"contentId":87,"url":100,"dateModified":22,"locale":36,"type":24},"Página no encontrada","\u002Fes\u002Fcontent\u002F404-pagina-no-encontrada",{"1":102,"2":126,"3":150,"4":175,"5":199},{"en":103,"fr":110,"nl":115,"de":118,"es":121},{"title":104,"path":105,"stem":106,"contentId":20,"dateModified":107,"url":108,"locale":23,"type":109},"Mastering Conditional Property Types with Vue 3.3 Generics","\u002Fposts\u002F1","posts\u002F1","2025-05-27T11:30:00","\u002Fposts\u002F1-mastering-conditional-property-types-with-vue-3_3-generics","blogPost",{"title":111,"path":112,"stem":113,"contentId":20,"dateModified":107,"url":114,"locale":27,"type":109},"Maîtriser les types de propriétés conditionnelles avec les génériques de Vue 3.3","\u002Farticles\u002F1","articles\u002F1","\u002Ffr\u002Farticles\u002F1-maitriser-les-types-de-proprietes-conditionnelles-avec-les-generiques-de-vue-3_3",{"title":116,"path":105,"stem":106,"contentId":20,"dateModified":107,"url":117,"locale":30,"type":109},"Conditionele Property Types beheersen met Vue 3.3 Generics","\u002Fnl\u002Fposts\u002F1-conditionele-property-types-beheersen-met-vue-3_3-generics",{"title":119,"path":105,"stem":106,"contentId":20,"dateModified":107,"url":120,"locale":33,"type":109},"Bedingte Property-Typen mit Vue 3.3 Generics meistern","\u002Fde\u002Fposts\u002F1-bedingte-property-typen-mit-vue-3_3-generics-meistern",{"title":122,"path":123,"stem":124,"contentId":20,"dateModified":107,"url":125,"locale":36,"type":109},"Dominar los tipos de propiedades condicionales con genéricos de Vue 3.3","\u002Farticulos\u002F1","articulos\u002F1","\u002Fes\u002Farticulos\u002F1-dominar-los-tipos-de-propiedades-condicionales-con-genericos-de-vue-3_3",{"en":127,"fr":134,"nl":139,"de":142,"es":145},{"title":128,"path":129,"stem":130,"contentId":131,"dateModified":132,"url":133,"locale":23,"type":109},"Ditching the Cookie Banners: Run Plausible Analytics on Azure Kubernetes","\u002Fposts\u002F2","posts\u002F2",2,"2025-10-04T13:00:00","\u002Fposts\u002F2-ditching-the-cookie-banners:-run-plausible-analytics-on-azure-kubernetes",{"title":135,"path":136,"stem":137,"contentId":131,"dateModified":132,"url":138,"locale":27,"type":109},"Éliminer les bannières de cookies: Exécutez Plausible Analytics sur Azure Kubernetes","\u002Farticles\u002F2","articles\u002F2","\u002Ffr\u002Farticles\u002F2-eliminer-les-bannieres-de-cookies:-executez-plausible-analytics-sur-azure-kubernetes",{"title":140,"path":129,"stem":130,"contentId":131,"dateModified":132,"url":141,"locale":30,"type":109},"Afscheid van Cookie Banners: Draai Plausible Analytics op Azure Kubernetes","\u002Fnl\u002Fposts\u002F2-afscheid-van-cookie-banners:-draai-plausible-analytics-op-azure-kubernetes",{"title":143,"path":129,"stem":130,"contentId":131,"dateModified":132,"url":144,"locale":33,"type":109},"Abschied von Cookie-Bannern: Plausible Analytics auf Azure Kubernetes betreiben","\u002Fde\u002Fposts\u002F2-abschied-von-cookie-bannern:-plausible-analytics-auf-azure-kubernetes-betreiben",{"title":146,"path":147,"stem":148,"contentId":131,"dateModified":132,"url":149,"locale":36,"type":109},"Eliminar los banners de cookies: Ejecuta Plausible Analytics en Azure Kubernetes","\u002Farticulos\u002F2","articulos\u002F2","\u002Fes\u002Farticulos\u002F2-eliminar-los-banners-de-cookies:-ejecuta-plausible-analytics-en-azure-kubernetes",{"en":151,"fr":158,"nl":164,"de":167,"es":170},{"title":152,"path":153,"stem":154,"contentId":155,"dateModified":156,"url":157,"locale":23,"type":109},"Track how many people read your articles, using Plausible.io, Vue.js and Azure functions","\u002Fposts\u002F3","posts\u002F3",3,"2025-08-03T15:45:00","\u002Fposts\u002F3-track-how-many-people-read-your-articles-using-plausible_io-vue_js-and-azure-functions",{"title":159,"path":160,"stem":161,"contentId":155,"dateModified":162,"url":163,"locale":27,"type":109},"Suivez combien de personnes lisent vos articles, en utilisant Plausible.io, Vue.js et Azure Functions","\u002Farticles\u002F3","articles\u002F3","2025-08-15:45:00","\u002Ffr\u002Farticles\u002F3-suivez-combien-de-personnes-lisent-vos-articles-en-utilisant-plausible_io-vue_js-et-azure-functions",{"title":165,"path":153,"stem":154,"contentId":155,"dateModified":156,"url":166,"locale":30,"type":109},"Volg hoeveel mensen je artikelen lezen, met Plausible.io, Vue.js en Azure functions","\u002Fnl\u002Fposts\u002F3-volg-hoeveel-mensen-je-artikelen-lezen-met-plausible_io-vue_js-en-azure-functions",{"title":168,"path":153,"stem":154,"contentId":155,"dateModified":156,"url":169,"locale":33,"type":109},"Verfolgen Sie, wie viele Menschen Ihre Artikel lesen, mit Plausible.io, Vue.js und Azure Functions","\u002Fde\u002Fposts\u002F3-verfolgen-sie-wie-viele-menschen-ihre-artikel-lesen-mit-plausible_io-vue_js-und-azure-functions",{"title":171,"path":172,"stem":173,"contentId":155,"dateModified":156,"url":174,"locale":36,"type":109},"Rastrea cuántas personas leen tus artículos, usando Plausible.io, Vue.js y funciones de Azure","\u002Farticulos\u002F3","articulos\u002F3","\u002Fes\u002Farticulos\u002F3-rastrea-cuantas-personas-leen-tus-articulos-usando-plausible_io-vue_js-y-funciones-de-azure",{"en":176,"fr":183,"nl":188,"de":191,"es":194},{"title":177,"path":178,"stem":179,"contentId":180,"dateModified":181,"url":182,"locale":23,"type":109},"Deploy a production-ready Kubernetes Cluster on Azure with Terraform","\u002Fposts\u002F4","posts\u002F4",4,"2025-12-06T21:00:00","\u002Fposts\u002F4-deploy-a-production-ready-kubernetes-cluster-on-azure-with-terraform",{"title":184,"path":185,"stem":186,"contentId":180,"dateModified":181,"url":187,"locale":27,"type":109},"Déployer un cluster Kubernetes prêt pour la production sur Azure avec Terraform","\u002Farticles\u002F4","articles\u002F4","\u002Ffr\u002Farticles\u002F4-deployer-un-cluster-kubernetes-pret-pour-la-production-sur-azure-avec-terraform",{"title":189,"path":178,"stem":179,"contentId":180,"dateModified":181,"url":190,"locale":30,"type":109},"Implementeer een productie-klaar Kubernetes Cluster op Azure met Terraform","\u002Fnl\u002Fposts\u002F4-implementeer-een-productie-klaar-kubernetes-cluster-op-azure-met-terraform",{"title":192,"path":178,"stem":179,"contentId":180,"dateModified":181,"url":193,"locale":33,"type":109},"Einen produktionsreifen Kubernetes-Cluster auf Azure mit Terraform bereitstellen","\u002Fde\u002Fposts\u002F4-einen-produktionsreifen-kubernetes-cluster-auf-azure-mit-terraform-bereitstellen",{"title":195,"path":196,"stem":197,"contentId":180,"dateModified":181,"url":198,"locale":36,"type":109},"Despliega un clúster de Kubernetes listo para producción en Azure con Terraform","\u002Farticulos\u002F4","articulos\u002F4","\u002Fes\u002Farticulos\u002F4-despliega-un-cluster-de-kubernetes-listo-para-produccion-en-azure-con-terraform",{"en":200,"fr":207,"nl":212,"de":215,"es":218},{"title":201,"path":202,"stem":203,"contentId":204,"dateModified":205,"url":206,"locale":23,"type":109},"Array to Map conversion in Typescript, with type safety","\u002Fposts\u002F5","posts\u002F5",5,"2025-09-29T21:00:00","\u002Fposts\u002F5-array-to-map-conversion-in-typescript-with-type-safety",{"title":208,"path":209,"stem":210,"contentId":204,"dateModified":205,"url":211,"locale":27,"type":109},"Conversion de tableau en Map en Typescript, avec sécurité des types","\u002Farticles\u002F5","articles\u002F5","\u002Ffr\u002Farticles\u002F5-conversion-de-tableau-en-map-en-typescript-avec-securite-des-types",{"title":213,"path":202,"stem":203,"contentId":204,"dateModified":205,"url":214,"locale":30,"type":109},"Array naar Map conversie in Typescript, met type veiligheid","\u002Fnl\u002Fposts\u002F5-array-naar-map-conversie-in-typescript-met-type-veiligheid",{"title":216,"path":202,"stem":203,"contentId":204,"dateModified":205,"url":217,"locale":33,"type":109},"Array zu Map Konvertierung in TypeScript, mit Typsicherheit","\u002Fde\u002Fposts\u002F5-array-zu-map-konvertierung-in-typescript-mit-typsicherheit",{"title":219,"path":220,"stem":221,"contentId":204,"dateModified":205,"url":222,"locale":36,"type":109},"Conversión de Array a Map en Typescript, con seguridad de tipos","\u002Farticulos\u002F5","articulos\u002F5","\u002Fes\u002Farticulos\u002F5-conversion-de-array-a-map-en-typescript-con-seguridad-de-tipos",{},{"id":225,"title":226,"body":227,"canonicalUrl":236,"company":236,"contentId":131,"dateModified":236,"datePublished":236,"description":233,"draft":237,"enableProse":237,"excludeFromNavigation":237,"extension":238,"imageAlt":236,"imageUrl":236,"meta":239,"navigation":245,"partial":245,"path":246,"seo":247,"slug":236,"stem":248,"url":249,"__hash__":250},"pages_en\u002Fcontent\u002F_footer.md","About Jeroen Bach",{"type":228,"value":229,"toc":234},"minimark",[230],[231,232,233],"p",{},"Designed in Figma and built with Vue.js, Nuxt.js and Tailwind CSS.\nDeployed via Azure Static Web App and Azure Functions.\nWebsite analytics are powered by Plausible Analytics, deployed using Azure Kubernetes Service.",{"title":21,"searchDepth":131,"depth":131,"links":235},[],null,false,"md",{"readingTime":240},{"text":241,"minutes":242,"time":243,"words":244},"1 min read",0.16,9600,32,true,"\u002Fcontent\u002F_footer",{"title":226,"description":233},"content\u002F_footer","\u002Fcontent\u002F2-about-jeroen-bach","tgPfOB73xkNtsI3Ers_EH08FHEHR7ko45cqNiVgrM0s",{"id":252,"title":253,"body":254,"canonicalUrl":236,"company":236,"contentId":155,"dateModified":236,"datePublished":236,"description":258,"draft":237,"enableProse":237,"excludeFromNavigation":237,"extension":238,"imageAlt":236,"imageUrl":236,"meta":261,"navigation":245,"partial":245,"path":266,"seo":267,"slug":236,"stem":268,"url":269,"__hash__":270},"pages_en\u002Fcontent\u002F_footer-about.md","Footer About",{"type":228,"value":255,"toc":259},[256],[231,257,258],{},"I'm a Software Engineer and Team Lead with over 15 years of professional experience.\nI'm passionate about solving complex problems through simple, elegant solutions.\nThis blog is where I share techniques and insights for building great software, inspired by real-world projects.",{"title":21,"searchDepth":131,"depth":131,"links":260},[],{"readingTime":262},{"text":241,"minutes":263,"time":264,"words":265},0.205,12300,41,"\u002Fcontent\u002F_footer-about",{"description":258},"content\u002F_footer-about","\u002Fcontent\u002F3-footer-about","49ofD3QAU2eKgnsoCR9zQhGEP7EECAsjUJeOHSF1GJI",{"id":272,"company":273,"extension":277,"fullName":278,"github":279,"homePage":280,"imageUrl":276,"linkedIn":281,"meta":282,"role":283,"stem":284,"twitter":285,"userName":286,"__hash__":287},"authors_en\u002Fauthors\u002Fjeroenbach.yaml",{"name":274,"url":275,"imageUrl":276},"Bach.Software","https:\u002F\u002Fbach.software","\u002FJEROEN-_A7R5652-HD-SQUARE-zoom.jpg","yaml","Jeroen Bach","https:\u002F\u002Fgithub.com\u002Fjeroenbach","https:\u002F\u002Fbach.software\u002Fpages\u002Fabout","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fjeroenbach\u002F",{},"Software Engineer \u002F Team Lead","authors\u002Fjeroenbach","https:\u002F\u002Fx.com\u002Fjeroenbach","jeroenbach","QjQ1vpL_EQbLZNaZdDNOjIkS-thm6oxHuAdoNIauNI8",{"id":289,"title":177,"authorName":286,"body":290,"canonicalUrl":236,"category":333,"contentId":180,"dateModified":181,"datePublished":2891,"description":2892,"draft":237,"excerpt":2893,"extension":238,"imageAlt":2899,"imagePosition":2900,"imageUrl":2901,"keywords":2902,"meta":2905,"navigation":245,"path":178,"readingTime":2906,"seo":2911,"slug":236,"stem":179,"url":182,"__hash__":2912,"author":2913},"posts_en\u002Fposts\u002F4.md",{"type":228,"value":291,"toc":2871},[292,300,320,339,349,354,388,391,417,421,449,459,463,472,475,589,595,598,616,621,636,642,718,736,739,742,774,777,788,792,811,821,827,831,834,846,850,853,1149,1209,1213,1216,1224,1470,1478,1652,1656,1659,1867,1871,1874,1877,1880,2216,2220,2223,2412,2416,2419,2597,2600,2603,2606,2612,2615,2643,2646,2653,2656,2675,2678,2687,2812,2815,2819,2825,2848,2852,2858,2864,2867],[231,293,294,295,299],{},"In this guide, you'll learn to create a fully modular and reusable Terraform solution,\ndeploying resources across ",[296,297,298],"strong",{},"Azure, Kubernetes, and Cloudflare",".",[301,302,303],"blockquote",{},[231,304,305,308,309,314,315,319],{},[296,306,307],{},"Update (Dec 2025):"," After running this Plausible deployment for a few months on the minimal Azure environment,\nI encountered memory shortages and disk space issues that caused the containers to crash repeatedly.\nThe two additional sections ",[310,311,313],"a",{"href":312},"#hurdle-memory-shortage-for-clickhouse-container","Hurdle: Memory shortage for clickhouse container","\nand ",[310,316,318],{"href":317},"#hurdle-disk-full-due-to-system-logs","Hurdle: Disk full due to system logs"," describe the root causes and the configuration changes that resolved these stability issues.",[231,321,322,323,326,327,330,331,334,335,338],{},"In my ",[310,324,325],{"href":133},"previous article",", you learned how to set up a ",[296,328,329],{},"Kubernetes cluster"," and run Plausible Analytics using a series of CLI commands.\nWhile that approach works, it isn't ideal. A better, more sustainable solution is to use ",[296,332,333],{},"Terraform",".\nWith Terraform, you describe your infrastructure in its ",[296,336,337],{},"desired state",", and Terraform figures out the steps required to get there.",[231,340,341,342,348],{},"I've also used ",[310,343,347],{"href":344,"rel":345},"https:\u002F\u002Fgithub.com\u002Fhelmfile\u002Fhelmfile",[346],"nofollow","helmfile"," before, which is great for managing Helm releases.\nBut what I like about Terraform is that it goes further. It doesn't stop at Kubernetes, it gives you a single solution to define all resources across your stack.",[350,351,353],"h2",{"id":352},"key-benefits","Key Benefits",[355,356,357,364,370,376,382],"ul",{},[358,359,360,363],"li",{},[296,361,362],{},"Define desired state"," - No more manual CLI scripts or configuration drift (your environment always represents your code)",[358,365,366,369],{},[296,367,368],{},"Recoverable"," - Since you've defined your desired state, it's easy to recover an entire environment with all its settings",[358,371,372,375],{},[296,373,374],{},"Modular Design"," - Create modules that can be reused across different environments",[358,377,378,381],{},[296,379,380],{},"Environment agnostic"," - Deploy to Azure, Kubernetes, Cloudflare, and many more platforms",[358,383,384,387],{},[296,385,386],{},"Version control"," - Track infrastructure changes in version control, maintaining a complete audit trail",[231,389,390],{},"Setting up your solution as described in this article extends those benefits with:",[355,392,393,399,405,411],{},[358,394,395,398],{},[296,396,397],{},"Automatic HTTPS"," - Let's Encrypt certificates for all your exposed applications",[358,400,401,404],{},[296,402,403],{},"Cost optimized"," - Only a single public IP, a cost-effective VM configuration and no extra VM disks",[358,406,407,410],{},[296,408,409],{},"No configuration drift"," - infrastructure state centrally stored",[358,412,413,416],{},[296,414,415],{},"Data safety"," - Azure disks store your data, enabling easy backup and restore via snapshots or backup-vault",[350,418,420],{"id":419},"prerequisites","Prerequisites",[231,422,423,424,427,428,430,431,434,435,438,439,441,442,430,446,299],{},"Ensure the following tools are installed on your machine: ",[296,425,426],{},"Azure CLI",", ",[296,429,333],{}," & ",[296,432,433],{},"Kubernetes tools",".\nOr alternatively use ",[296,436,437],{},"Cloud Shell"," in Azure Portal, where all needed tools are pre-installed. When using ",[296,440,437],{},", do mount the clouddrive to persist everything across shell sessions: ",[443,444,445],"code",{},"clouddrive mount",[443,447,448],{},"cd clouddrive",[231,450,451,452,454,455,458],{},"If you're on Windows, please use ",[296,453,437],{}," or ",[296,456,457],{},"WSL",", as all scripts are written in bash.",[350,460,462],{"id":461},"getting-started","Getting Started",[231,464,465,466,471],{},"There's one small manual step to take before we can dive into Terraform.\nOur solution needs a backend (storage) for the state file, so we first need to create a Storage Account with a container in Azure.\nThere's a handy script in our ",[310,467,470],{"href":468,"rel":469},"https:\u002F\u002Fgithub.com\u002Fjeroenbach\u002Fbach.software\u002Ftree\u002Fmain\u002Fsrc\u002Fapp\u002Fexamples\u002Fpost4\u002Fterraform",[346],"Terraform project"," that does this for you. So let's get our Terraform project first.",[231,473,474],{},"You can do this quickly by running the following shell script:",[476,477,478],"code-group",{},[479,480,485],"pre",{"className":481,"code":482,"filename":483,"language":484,"meta":21,"style":21},"language-bash shiki shiki-themes github-light github-dark","#!\u002Fusr\u002Fbin\u002Fenv bash\n# Download and extract the terraform project from the repository\ncurl -L \"https:\u002F\u002Fgithub.com\u002Fjeroenbach\u002Fbach.software\u002Farchive\u002Frefs\u002Fheads\u002Fmain.zip\" -o \"bach.software-terraform.zip\"\nunzip -q \"bach.software-terraform.zip\" \"bach.software-main\u002Fsrc\u002Fapp\u002Fexamples\u002Fpost4\u002Fterraform\u002F*\"\n\n# Move the extracted folder to current directory and remove the zip and extracted folder\nmv \"bach.software-main\u002Fsrc\u002Fapp\u002Fexamples\u002Fpost4\u002Fterraform\" \".\u002Fterraform\"\nrm -rf \"bach.software-terraform.zip\" \"bach.software-main\"\n\n# Navigate to the terraform directory\ncd \".\u002Fterraform\"\n","download-terraform-project.sh","bash",[443,486,487,495,500,520,534,539,545,557,571,576,581],{"__ignoreMap":21},[488,489,491],"span",{"class":490,"line":20},"line",[488,492,494],{"class":493},"sJ8bj","#!\u002Fusr\u002Fbin\u002Fenv bash\n",[488,496,497],{"class":490,"line":131},[488,498,499],{"class":493},"# Download and extract the terraform project from the repository\n",[488,501,502,506,510,514,517],{"class":490,"line":155},[488,503,505],{"class":504},"sScJk","curl",[488,507,509],{"class":508},"sj4cs"," -L",[488,511,513],{"class":512},"sZZnC"," \"https:\u002F\u002Fgithub.com\u002Fjeroenbach\u002Fbach.software\u002Farchive\u002Frefs\u002Fheads\u002Fmain.zip\"",[488,515,516],{"class":508}," -o",[488,518,519],{"class":512}," \"bach.software-terraform.zip\"\n",[488,521,522,525,528,531],{"class":490,"line":180},[488,523,524],{"class":504},"unzip",[488,526,527],{"class":508}," -q",[488,529,530],{"class":512}," \"bach.software-terraform.zip\"",[488,532,533],{"class":512}," \"bach.software-main\u002Fsrc\u002Fapp\u002Fexamples\u002Fpost4\u002Fterraform\u002F*\"\n",[488,535,536],{"class":490,"line":204},[488,537,538],{"emptyLinePlaceholder":245},"\n",[488,540,542],{"class":490,"line":541},6,[488,543,544],{"class":493},"# Move the extracted folder to current directory and remove the zip and extracted folder\n",[488,546,548,551,554],{"class":490,"line":547},7,[488,549,550],{"class":504},"mv",[488,552,553],{"class":512}," \"bach.software-main\u002Fsrc\u002Fapp\u002Fexamples\u002Fpost4\u002Fterraform\"",[488,555,556],{"class":512}," \".\u002Fterraform\"\n",[488,558,560,563,566,568],{"class":490,"line":559},8,[488,561,562],{"class":504},"rm",[488,564,565],{"class":508}," -rf",[488,567,530],{"class":512},[488,569,570],{"class":512}," \"bach.software-main\"\n",[488,572,574],{"class":490,"line":573},9,[488,575,538],{"emptyLinePlaceholder":245},[488,577,578],{"class":490,"line":41},[488,579,580],{"class":493},"# Navigate to the terraform directory\n",[488,582,584,587],{"class":490,"line":583},11,[488,585,586],{"class":508},"cd",[488,588,556],{"class":512},[231,590,591,592,299],{},"Before running Terraform or any of the next scripts, always make sure you're logged in to the correct Azure subscription using ",[443,593,594],{},"az login",[231,596,597],{},"Let's create the storage account and container for our Terraform state:",[479,599,601],{"className":481,"code":600,"language":484,"meta":21,"style":21},"az login\n.\u002Fscripts\u002Fcreate-tfstate-storage.sh\n",[443,602,603,611],{"__ignoreMap":21},[488,604,605,608],{"class":490,"line":20},[488,606,607],{"class":504},"az",[488,609,610],{"class":512}," login\n",[488,612,613],{"class":490,"line":131},[488,614,615],{"class":504},".\u002Fscripts\u002Fcreate-tfstate-storage.sh\n",[617,618,620],"h3",{"id":619},"running-terraform","Running Terraform",[231,622,623,624,627,628,631,632,635],{},"When running ",[443,625,626],{},"terraform apply",", Terraform will ask for some input variables. You can find the needed values in the ",[296,629,630],{},"input-output.tf"," file in the same folder.\nYou can also create a ",[296,633,634],{},"terraform.tfvars"," file with the needed values, so you don't have to enter them each time.",[231,637,638,641],{},[296,639,640],{},"Note",": Make sure not to check in your .tfvars files to version control",[476,643,644],{},[479,645,649],{"className":646,"code":647,"filename":634,"language":648,"meta":21,"style":21},"language-hcl shiki shiki-themes github-light github-dark","azure_subscription_id = \"\u003Cazure-subscription-id>\"\nazure_cluster_name    = \"aks-westeu-prod\"\ncloudflare_api_token  = \"\u003Ccloudflare-api-token>\"\ncloudflare_zone_id    = \"\u003Ccloudflare-zone-id>\"\nplausible_dns         = \"plausible.example.com\"\nletsencrypt_email     = \"admin@example.com\"\n","hcl",[443,650,651,664,675,686,696,707],{"__ignoreMap":21},[488,652,653,657,661],{"class":490,"line":20},[488,654,656],{"class":655},"sVt8B","azure_subscription_id",[488,658,660],{"class":659},"szBVR"," =",[488,662,663],{"class":512}," \"\u003Cazure-subscription-id>\"\n",[488,665,666,669,672],{"class":490,"line":131},[488,667,668],{"class":655},"azure_cluster_name",[488,670,671],{"class":659},"    =",[488,673,674],{"class":512}," \"aks-westeu-prod\"\n",[488,676,677,680,683],{"class":490,"line":155},[488,678,679],{"class":655},"cloudflare_api_token",[488,681,682],{"class":659},"  =",[488,684,685],{"class":512}," \"\u003Ccloudflare-api-token>\"\n",[488,687,688,691,693],{"class":490,"line":180},[488,689,690],{"class":655},"cloudflare_zone_id",[488,692,671],{"class":659},[488,694,695],{"class":512}," \"\u003Ccloudflare-zone-id>\"\n",[488,697,698,701,704],{"class":490,"line":204},[488,699,700],{"class":655},"plausible_dns",[488,702,703],{"class":659},"         =",[488,705,706],{"class":512}," \"plausible.example.com\"\n",[488,708,709,712,715],{"class":490,"line":541},[488,710,711],{"class":655},"letsencrypt_email",[488,713,714],{"class":659},"     =",[488,716,717],{"class":512}," \"admin@example.com\"\n",[231,719,720,721,726,727,731,732,299],{},"Before starting, make sure you have the required information available. You can create a free Cloudflare account and link it to a DNS you own, or create a new DNS.\nYou can create an API token following ",[310,722,725],{"href":723,"rel":724},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Ffundamentals\u002Fapi\u002Fget-started\u002Fcreate-token\u002F",[346],"these instructions"," and use the \"Edit zone DNS\" template.\nYou can find your zone ID following ",[310,728,725],{"href":729,"rel":730},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Ffundamentals\u002Faccount\u002Ffind-account-and-zone-ids\u002F",[346],".\nYou can find your Azure subscription ID following ",[310,733,725],{"href":734,"rel":735},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fazure\u002Fazure-portal\u002Fget-subscription-tenant-id",[346],[231,737,738],{},"If you don't specify the Cloudflare variables, the DNS won't be updated, but everything else will still work and you'll be shown the IP address (to use to access Plausible) at the end.\nYou do need to create a DNS record with this IP address yourself, as the certificate issuer needs it to validate the DNS record before it can issue a valid certificate.",[231,740,741],{},"Now, let's deploy your environment:",[479,743,745],{"className":481,"code":744,"language":484,"meta":21,"style":21},"# Environment name: Azure Kubernetes Service - Western Europe - Production\ncd aks-westeu-prod\nterraform init\nterraform apply\n",[443,746,747,752,759,767],{"__ignoreMap":21},[488,748,749],{"class":490,"line":20},[488,750,751],{"class":493},"# Environment name: Azure Kubernetes Service - Western Europe - Production\n",[488,753,754,756],{"class":490,"line":131},[488,755,586],{"class":508},[488,757,758],{"class":512}," aks-westeu-prod\n",[488,760,761,764],{"class":490,"line":155},[488,762,763],{"class":504},"terraform",[488,765,766],{"class":512}," init\n",[488,768,769,771],{"class":490,"line":180},[488,770,763],{"class":504},[488,772,773],{"class":512}," apply\n",[231,775,776],{},"Terraform will:",[355,778,779,782,785],{},[358,780,781],{},"Deploy the AKS cluster",[358,783,784],{},"Install Plausible via Helm",[358,786,787],{},"Update Cloudflare DNS",[350,789,791],{"id":790},"backup-restore-optional","Backup & Restore (Optional)",[231,793,794,795,798,799,430,802,805,806,299],{},"In your ",[296,796,797],{},"rg-nodes-aks-westeu-prod"," resource group, you'll find the two Azure disks that contain all the data of the Plausible solution:\n",[296,800,801],{},"pv-disk-plausible-analytics-v3-clickhouse-0",[296,803,804],{},"pv-disk-plausible-analytics-v3-postgresql-0",".\nYou can create hourly, daily, or weekly backups of those disks using ",[310,807,810],{"href":808,"rel":809},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fazure\u002Fbackup\u002Fbackup-managed-disks",[346],"Azure Backup Vault",[231,812,813,814,817,818,299],{},"To restore a backup, create a snapshot of the specific backup to a resource group and fill in the snapshot IDs in the following variables found in the aks-westeu-prod\u002Finput-output.tf file: ",[296,815,816],{},"postgresql_restore_snapshot_id"," and ",[296,819,820],{},"clickhouse_restore_snapshot_id",[231,822,823,824,826],{},"Next time you run ",[443,825,626],{},", Plausible will be restored with the backups.",[350,828,830],{"id":829},"destroying-the-environment","Destroying the Environment",[231,832,833],{},"To destroy the environment and all associated resources, you can run the following command:",[479,835,837],{"className":481,"code":836,"language":484,"meta":21,"style":21},"terraform destroy\n",[443,838,839],{"__ignoreMap":21},[488,840,841,843],{"class":490,"line":20},[488,842,763],{"class":504},[488,844,845],{"class":512}," destroy\n",[350,847,849],{"id":848},"solution-structure","Solution Structure",[231,851,852],{},"To make the solution run from beginning to end, there were some hurdles to overcome. In this chapter, I'll examine those hurdles and how I've solved them, but first let me provide a general overview of the solution.",[479,854,856],{"className":481,"code":855,"language":484,"meta":21,"style":21},"terraform\u002F\n├── aks-westeu-prod\u002F\n│   ├── app-plausible.tf\n│   ├── aks-cluster.tf\n│   └── provider.tf\n├── helm-charts\u002F\n│   ├── letsencrypt-cert-issuer\u002F\n│   │   ├── templates\u002F\n│   │   │   ├── letsencrypt-cluster-issuer-staging.yaml\n│   │   │   └── letsencrypt-cluster-issuer.yaml\n│   │   ├── Chart.yaml\n│   │   └── values.yaml\n├── modules\u002F\n│   ├── aks-cluster\u002F\n│   │   ├── aks-cluster.tf\n│   │   ├── ingress-and-certificates.tf\n│   │   └── input-output.tf\n│   ├── persistent-azure-disk-volume\u002F\n│   │   ├── input.tf\n│   │   └── persistent-azure-disk-volume.tf\n│   └── plausible\u002F\n│   │   ├── disks.tf\n│   │   ├── input.tf\n│   │   ├── namespace.tf\n│   │   └── plausible.tf\n├── scripts\u002F\n│   ├── create-tfstate-storage.sh\n│   └── download-terraform-project.sh\n",[443,857,858,863,871,882,891,901,908,917,929,942,955,966,978,986,996,1007,1019,1031,1041,1053,1064,1074,1086,1097,1109,1121,1129,1139],{"__ignoreMap":21},[488,859,860],{"class":490,"line":20},[488,861,862],{"class":504},"terraform\u002F\n",[488,864,865,868],{"class":490,"line":131},[488,866,867],{"class":504},"├──",[488,869,870],{"class":512}," aks-westeu-prod\u002F\n",[488,872,873,876,879],{"class":490,"line":155},[488,874,875],{"class":504},"│",[488,877,878],{"class":512},"   ├──",[488,880,881],{"class":512}," app-plausible.tf\n",[488,883,884,886,888],{"class":490,"line":180},[488,885,875],{"class":504},[488,887,878],{"class":512},[488,889,890],{"class":512}," aks-cluster.tf\n",[488,892,893,895,898],{"class":490,"line":204},[488,894,875],{"class":504},[488,896,897],{"class":512},"   └──",[488,899,900],{"class":512}," provider.tf\n",[488,902,903,905],{"class":490,"line":541},[488,904,867],{"class":504},[488,906,907],{"class":512}," helm-charts\u002F\n",[488,909,910,912,914],{"class":490,"line":547},[488,911,875],{"class":504},[488,913,878],{"class":512},[488,915,916],{"class":512}," letsencrypt-cert-issuer\u002F\n",[488,918,919,921,924,926],{"class":490,"line":559},[488,920,875],{"class":504},[488,922,923],{"class":512},"   │",[488,925,878],{"class":512},[488,927,928],{"class":512}," templates\u002F\n",[488,930,931,933,935,937,939],{"class":490,"line":573},[488,932,875],{"class":504},[488,934,923],{"class":512},[488,936,923],{"class":512},[488,938,878],{"class":512},[488,940,941],{"class":512}," letsencrypt-cluster-issuer-staging.yaml\n",[488,943,944,946,948,950,952],{"class":490,"line":41},[488,945,875],{"class":504},[488,947,923],{"class":512},[488,949,923],{"class":512},[488,951,897],{"class":512},[488,953,954],{"class":512}," letsencrypt-cluster-issuer.yaml\n",[488,956,957,959,961,963],{"class":490,"line":583},[488,958,875],{"class":504},[488,960,923],{"class":512},[488,962,878],{"class":512},[488,964,965],{"class":512}," Chart.yaml\n",[488,967,969,971,973,975],{"class":490,"line":968},12,[488,970,875],{"class":504},[488,972,923],{"class":512},[488,974,897],{"class":512},[488,976,977],{"class":512}," values.yaml\n",[488,979,981,983],{"class":490,"line":980},13,[488,982,867],{"class":504},[488,984,985],{"class":512}," modules\u002F\n",[488,987,989,991,993],{"class":490,"line":988},14,[488,990,875],{"class":504},[488,992,878],{"class":512},[488,994,995],{"class":512}," aks-cluster\u002F\n",[488,997,999,1001,1003,1005],{"class":490,"line":998},15,[488,1000,875],{"class":504},[488,1002,923],{"class":512},[488,1004,878],{"class":512},[488,1006,890],{"class":512},[488,1008,1010,1012,1014,1016],{"class":490,"line":1009},16,[488,1011,875],{"class":504},[488,1013,923],{"class":512},[488,1015,878],{"class":512},[488,1017,1018],{"class":512}," ingress-and-certificates.tf\n",[488,1020,1022,1024,1026,1028],{"class":490,"line":1021},17,[488,1023,875],{"class":504},[488,1025,923],{"class":512},[488,1027,897],{"class":512},[488,1029,1030],{"class":512}," input-output.tf\n",[488,1032,1034,1036,1038],{"class":490,"line":1033},18,[488,1035,875],{"class":504},[488,1037,878],{"class":512},[488,1039,1040],{"class":512}," persistent-azure-disk-volume\u002F\n",[488,1042,1044,1046,1048,1050],{"class":490,"line":1043},19,[488,1045,875],{"class":504},[488,1047,923],{"class":512},[488,1049,878],{"class":512},[488,1051,1052],{"class":512}," input.tf\n",[488,1054,1055,1057,1059,1061],{"class":490,"line":55},[488,1056,875],{"class":504},[488,1058,923],{"class":512},[488,1060,897],{"class":512},[488,1062,1063],{"class":512}," persistent-azure-disk-volume.tf\n",[488,1065,1067,1069,1071],{"class":490,"line":1066},21,[488,1068,875],{"class":504},[488,1070,897],{"class":512},[488,1072,1073],{"class":512}," plausible\u002F\n",[488,1075,1077,1079,1081,1083],{"class":490,"line":1076},22,[488,1078,875],{"class":504},[488,1080,923],{"class":512},[488,1082,878],{"class":512},[488,1084,1085],{"class":512}," disks.tf\n",[488,1087,1089,1091,1093,1095],{"class":490,"line":1088},23,[488,1090,875],{"class":504},[488,1092,923],{"class":512},[488,1094,878],{"class":512},[488,1096,1052],{"class":512},[488,1098,1100,1102,1104,1106],{"class":490,"line":1099},24,[488,1101,875],{"class":504},[488,1103,923],{"class":512},[488,1105,878],{"class":512},[488,1107,1108],{"class":512}," namespace.tf\n",[488,1110,1112,1114,1116,1118],{"class":490,"line":1111},25,[488,1113,875],{"class":504},[488,1115,923],{"class":512},[488,1117,897],{"class":512},[488,1119,1120],{"class":512}," plausible.tf\n",[488,1122,1124,1126],{"class":490,"line":1123},26,[488,1125,867],{"class":504},[488,1127,1128],{"class":512}," scripts\u002F\n",[488,1130,1132,1134,1136],{"class":490,"line":1131},27,[488,1133,875],{"class":504},[488,1135,878],{"class":512},[488,1137,1138],{"class":512}," create-tfstate-storage.sh\n",[488,1140,1142,1144,1146],{"class":490,"line":1141},28,[488,1143,875],{"class":504},[488,1145,897],{"class":512},[488,1147,1148],{"class":512}," download-terraform-project.sh\n",[355,1150,1151,1161,1175],{},[358,1152,1153,1156,1157,1160],{},[443,1154,1155],{},"aks-westeu-prod",": A production environment configuration for deploying to Azure West Europe. You can use this folder as a template to create more environments. The files prefixed with ",[443,1158,1159],{},"app-"," show the different applications installed in the cluster.",[358,1162,1163,1166,1167],{},[443,1164,1165],{},"helm-charts",": Custom Helm charts\n",[355,1168,1169],{},[358,1170,1171,1174],{},[443,1172,1173],{},"letsencrypt-cert-issuer",": Instead of deploying the ClusterIssuer resources separately, I packaged them in a Helm chart",[358,1176,1177,1180,1181],{},[443,1178,1179],{},"modules",": Each module encapsulates a specific responsibility\n",[355,1182,1183,1189,1203],{},[358,1184,1185,1188],{},[443,1186,1187],{},"aks-cluster",": Deploys an AKS cluster with Let's Encrypt certificate issuer, nginx ingress as load balancer, and waits for the public IP to be available",[358,1190,1191,1194,1195,817,1199,1202],{},[443,1192,1193],{},"persistent-azure-disk-volume",": Creates an Azure disk or restores one using a snapshot and then creates a ",[1196,1197,1198],"em",{},"persistent volume",[1196,1200,1201],{},"persistent volume claim"," in Kubernetes",[358,1204,1205,1208],{},[443,1206,1207],{},"plausible",": Installs Plausible and its dependencies via Helm",[617,1210,1212],{"id":1211},"hurdle-connection-details-of-the-new-cluster-not-yet-available","Hurdle: Connection details of the new cluster not yet available",[231,1214,1215],{},"After creating the Kubernetes cluster, we want to be able to deploy resources to it. But at the Terraform plan stage, information on how to connect to this new environment is not yet available. Therefore, we had to take two steps to create a seamless deployment.",[355,1217,1218],{},[358,1219,1220,1223],{},[296,1221,1222],{},"Dynamic Provider Configuration",": The AKS cluster's information is dynamically set for the Helm and Kubernetes providers by retrieving the connection information from the newly created cluster:",[476,1225,1226],{},[479,1227,1230],{"className":646,"code":1228,"filename":1229,"language":648,"meta":21,"style":21},"provider \"helm\" {\n  kubernetes = {\n    # Use dynamic provider configuration to use the newly created cluster directly\n    host                   = module.aks_cluster.kube_config.host\n    client_certificate     = base64decode(module.aks_cluster.kube_config.client_certificate)\n    client_key             = base64decode(module.aks_cluster.kube_config.client_key)\n    cluster_ca_certificate = base64decode(module.aks_cluster.kube_config.cluster_ca_certificate)\n  }\n}\n\nprovider \"kubernetes\" {\n  # Use dynamic provider configuration to use the newly created cluster directly\n  host                   = module.aks_cluster.kube_config.host\n  client_certificate     = base64decode(module.aks_cluster.kube_config.client_certificate)\n  client_key             = base64decode(module.aks_cluster.kube_config.client_key)\n  cluster_ca_certificate = base64decode(module.aks_cluster.kube_config.cluster_ca_certificate)\n}\n","aks-westeu-prod\u002Fprovider.tf",[443,1231,1232,1243,1252,1257,1268,1296,1320,1344,1349,1354,1358,1367,1372,1396,1419,1443,1466],{"__ignoreMap":21},[488,1233,1234,1237,1240],{"class":490,"line":20},[488,1235,1236],{"class":504},"provider",[488,1238,1239],{"class":508}," \"helm\"",[488,1241,1242],{"class":655}," {\n",[488,1244,1245,1248,1250],{"class":490,"line":131},[488,1246,1247],{"class":655},"  kubernetes",[488,1249,660],{"class":659},[488,1251,1242],{"class":655},[488,1253,1254],{"class":490,"line":155},[488,1255,1256],{"class":493},"    # Use dynamic provider configuration to use the newly created cluster directly\n",[488,1258,1259,1262,1265],{"class":490,"line":180},[488,1260,1261],{"class":655},"    host                   ",[488,1263,1264],{"class":659},"=",[488,1266,1267],{"class":655}," module.aks_cluster.kube_config.host\n",[488,1269,1270,1273,1275,1278,1281,1283,1286,1288,1291,1293],{"class":490,"line":204},[488,1271,1272],{"class":655},"    client_certificate     ",[488,1274,1264],{"class":659},[488,1276,1277],{"class":508}," base64decode",[488,1279,1280],{"class":655},"(module",[488,1282,299],{"class":659},[488,1284,1285],{"class":655},"aks_cluster",[488,1287,299],{"class":659},[488,1289,1290],{"class":655},"kube_config",[488,1292,299],{"class":659},[488,1294,1295],{"class":655},"client_certificate)\n",[488,1297,1298,1301,1303,1305,1307,1309,1311,1313,1315,1317],{"class":490,"line":541},[488,1299,1300],{"class":655},"    client_key             ",[488,1302,1264],{"class":659},[488,1304,1277],{"class":508},[488,1306,1280],{"class":655},[488,1308,299],{"class":659},[488,1310,1285],{"class":655},[488,1312,299],{"class":659},[488,1314,1290],{"class":655},[488,1316,299],{"class":659},[488,1318,1319],{"class":655},"client_key)\n",[488,1321,1322,1325,1327,1329,1331,1333,1335,1337,1339,1341],{"class":490,"line":547},[488,1323,1324],{"class":655},"    cluster_ca_certificate ",[488,1326,1264],{"class":659},[488,1328,1277],{"class":508},[488,1330,1280],{"class":655},[488,1332,299],{"class":659},[488,1334,1285],{"class":655},[488,1336,299],{"class":659},[488,1338,1290],{"class":655},[488,1340,299],{"class":659},[488,1342,1343],{"class":655},"cluster_ca_certificate)\n",[488,1345,1346],{"class":490,"line":559},[488,1347,1348],{"class":655},"  }\n",[488,1350,1351],{"class":490,"line":573},[488,1352,1353],{"class":655},"}\n",[488,1355,1356],{"class":490,"line":41},[488,1357,538],{"emptyLinePlaceholder":245},[488,1359,1360,1362,1365],{"class":490,"line":583},[488,1361,1236],{"class":504},[488,1363,1364],{"class":508}," \"kubernetes\"",[488,1366,1242],{"class":655},[488,1368,1369],{"class":490,"line":968},[488,1370,1371],{"class":493},"  # Use dynamic provider configuration to use the newly created cluster directly\n",[488,1373,1374,1377,1380,1383,1385,1387,1389,1391,1393],{"class":490,"line":980},[488,1375,1376],{"class":655},"  host",[488,1378,1379],{"class":659},"                   =",[488,1381,1382],{"class":655}," module",[488,1384,299],{"class":659},[488,1386,1285],{"class":655},[488,1388,299],{"class":659},[488,1390,1290],{"class":655},[488,1392,299],{"class":659},[488,1394,1395],{"class":655},"host\n",[488,1397,1398,1401,1403,1405,1407,1409,1411,1413,1415,1417],{"class":490,"line":988},[488,1399,1400],{"class":655},"  client_certificate",[488,1402,714],{"class":659},[488,1404,1277],{"class":508},[488,1406,1280],{"class":655},[488,1408,299],{"class":659},[488,1410,1285],{"class":655},[488,1412,299],{"class":659},[488,1414,1290],{"class":655},[488,1416,299],{"class":659},[488,1418,1295],{"class":655},[488,1420,1421,1424,1427,1429,1431,1433,1435,1437,1439,1441],{"class":490,"line":998},[488,1422,1423],{"class":655},"  client_key",[488,1425,1426],{"class":659},"             =",[488,1428,1277],{"class":508},[488,1430,1280],{"class":655},[488,1432,299],{"class":659},[488,1434,1285],{"class":655},[488,1436,299],{"class":659},[488,1438,1290],{"class":655},[488,1440,299],{"class":659},[488,1442,1319],{"class":655},[488,1444,1445,1448,1450,1452,1454,1456,1458,1460,1462,1464],{"class":490,"line":1009},[488,1446,1447],{"class":655},"  cluster_ca_certificate",[488,1449,660],{"class":659},[488,1451,1277],{"class":508},[488,1453,1280],{"class":655},[488,1455,299],{"class":659},[488,1457,1285],{"class":655},[488,1459,299],{"class":659},[488,1461,1290],{"class":655},[488,1463,299],{"class":659},[488,1465,1343],{"class":655},[488,1467,1468],{"class":490,"line":1021},[488,1469,1353],{"class":655},[355,1471,1472],{},[358,1473,1474,1477],{},[296,1475,1476],{},"Set the local kubectl context",": After the AKS cluster is created, we write the new kube config and set the kubectl context on the local machine, this way local-exec commands can immediately connect to the new cluster.",[476,1479,1480],{},[479,1481,1484],{"className":646,"code":1482,"filename":1483,"language":648,"meta":21,"style":21},"resource \"null_resource\" \"set_kube_context\" {\n  provisioner \"local-exec\" {\n    command = \u003C\u003CEOT\n      # We get it from the Terraform state and add it to the kubeconfig\n      echo '${azurerm_kubernetes_cluster.aks_cluster.kube_config_raw}' > ~\u002F.kube\u002Fconfig\n      export KUBECONFIG=~\u002F.kube\u002Fconfig\n      kubectl config use-context ${azurerm_kubernetes_cluster.aks_cluster.name}\n    EOT\n  }\n\n  \u002F\u002F Always set the kube context when running apply, even if no changes were made to the cluster\n  triggers = {\n    always_run = \"${timestamp()}\"\n  }\n\n  depends_on = [azurerm_kubernetes_cluster.aks_cluster]\n}\n","modules\u002Faks-cluster\u002Faks-cluster.tf",[443,1485,1486,1499,1509,1519,1524,1550,1555,1575,1580,1584,1588,1593,1602,1625,1629,1633,1648],{"__ignoreMap":21},[488,1487,1488,1491,1494,1497],{"class":490,"line":20},[488,1489,1490],{"class":504},"resource",[488,1492,1493],{"class":508}," \"null_resource\"",[488,1495,1496],{"class":508}," \"set_kube_context\"",[488,1498,1242],{"class":655},[488,1500,1501,1504,1507],{"class":490,"line":131},[488,1502,1503],{"class":504},"  provisioner",[488,1505,1506],{"class":508}," \"local-exec\"",[488,1508,1242],{"class":655},[488,1510,1511,1514,1516],{"class":490,"line":155},[488,1512,1513],{"class":655},"    command",[488,1515,660],{"class":659},[488,1517,1518],{"class":659}," \u003C\u003CEOT\n",[488,1520,1521],{"class":490,"line":180},[488,1522,1523],{"class":512},"      # We get it from the Terraform state and add it to the kubeconfig\n",[488,1525,1526,1529,1532,1535,1537,1539,1541,1544,1547],{"class":490,"line":204},[488,1527,1528],{"class":512},"      echo '",[488,1530,1531],{"class":659},"${",[488,1533,1534],{"class":655},"azurerm_kubernetes_cluster",[488,1536,299],{"class":659},[488,1538,1285],{"class":655},[488,1540,299],{"class":659},[488,1542,1543],{"class":655},"kube_config_raw",[488,1545,1546],{"class":659},"}",[488,1548,1549],{"class":512},"' > ~\u002F.kube\u002Fconfig\n",[488,1551,1552],{"class":490,"line":541},[488,1553,1554],{"class":512},"      export KUBECONFIG=~\u002F.kube\u002Fconfig\n",[488,1556,1557,1560,1562,1564,1566,1568,1570,1573],{"class":490,"line":547},[488,1558,1559],{"class":512},"      kubectl config use-context ",[488,1561,1531],{"class":659},[488,1563,1534],{"class":655},[488,1565,299],{"class":659},[488,1567,1285],{"class":655},[488,1569,299],{"class":659},[488,1571,1572],{"class":655},"name",[488,1574,1353],{"class":659},[488,1576,1577],{"class":490,"line":559},[488,1578,1579],{"class":659},"    EOT\n",[488,1581,1582],{"class":490,"line":573},[488,1583,1348],{"class":655},[488,1585,1586],{"class":490,"line":41},[488,1587,538],{"emptyLinePlaceholder":245},[488,1589,1590],{"class":490,"line":583},[488,1591,1592],{"class":493},"  \u002F\u002F Always set the kube context when running apply, even if no changes were made to the cluster\n",[488,1594,1595,1598,1600],{"class":490,"line":968},[488,1596,1597],{"class":655},"  triggers",[488,1599,660],{"class":659},[488,1601,1242],{"class":655},[488,1603,1604,1607,1609,1612,1614,1617,1620,1622],{"class":490,"line":980},[488,1605,1606],{"class":655},"    always_run ",[488,1608,1264],{"class":659},[488,1610,1611],{"class":512}," \"",[488,1613,1531],{"class":659},[488,1615,1616],{"class":508},"timestamp",[488,1618,1619],{"class":512},"()",[488,1621,1546],{"class":659},[488,1623,1624],{"class":512},"\"\n",[488,1626,1627],{"class":490,"line":988},[488,1628,1348],{"class":655},[488,1630,1631],{"class":490,"line":998},[488,1632,538],{"emptyLinePlaceholder":245},[488,1634,1635,1638,1640,1643,1645],{"class":490,"line":1009},[488,1636,1637],{"class":655},"  depends_on",[488,1639,660],{"class":659},[488,1641,1642],{"class":655}," [azurerm_kubernetes_cluster",[488,1644,299],{"class":659},[488,1646,1647],{"class":655},"aks_cluster]\n",[488,1649,1650],{"class":490,"line":1021},[488,1651,1353],{"class":655},[617,1653,1655],{"id":1654},"hurdle-load-balancer-ip-not-yet-available","Hurdle: Load Balancer IP not yet available",[231,1657,1658],{},"When deploying a helm release, terraform finishes before the release is completely deployed. It also doesn't provide the load balancer IP information. Therefore I implemented two local scripts that wait for the nginx ingress deployment and collect the load balancer IP, which is needed to update your DNS.",[476,1660,1661],{},[479,1662,1665],{"className":646,"code":1663,"filename":1664,"language":648,"meta":21,"style":21},"# Wait for the ingress-nginx helm release to be deployed\nresource \"null_resource\" \"wait_for_ingress_nginx\" {\n  provisioner \"local-exec\" {\n    command = \u003C\u003CEOT\n      for i in {1..30}; do\n        kubectl get svc -n ingress-nginx ${helm_release.ingress_nginx.name}-controller && sleep 30 && break || sleep 30;\n      done\n    EOT\n  }\n\n  depends_on = [helm_release.ingress_nginx]\n}\n\n# Get external IP using kubectl\ndata \"external\" \"ingress_external_ip\" {\n  program = [\"bash\", \"-c\", \u003C\u003CEOT\n    EXTERNAL_IP=$(kubectl get svc -n ingress-nginx ${helm_release.ingress_nginx.name}-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>\u002Fdev\u002Fnull || echo \"\")\n    echo \"{\\\"ip\\\":\\\"$EXTERNAL_IP\\\"}\"\n  EOT\n  ]\n\n  depends_on = [null_resource.wait_for_ingress_nginx]\n}\n","modules\u002Faks-cluster\u002Fingress-and-certificates.tf",[443,1666,1667,1672,1683,1691,1699,1704,1728,1733,1737,1741,1745,1759,1763,1767,1772,1785,1808,1830,1835,1840,1845,1849,1863],{"__ignoreMap":21},[488,1668,1669],{"class":490,"line":20},[488,1670,1671],{"class":493},"# Wait for the ingress-nginx helm release to be deployed\n",[488,1673,1674,1676,1678,1681],{"class":490,"line":131},[488,1675,1490],{"class":504},[488,1677,1493],{"class":508},[488,1679,1680],{"class":508}," \"wait_for_ingress_nginx\"",[488,1682,1242],{"class":655},[488,1684,1685,1687,1689],{"class":490,"line":155},[488,1686,1503],{"class":504},[488,1688,1506],{"class":508},[488,1690,1242],{"class":655},[488,1692,1693,1695,1697],{"class":490,"line":180},[488,1694,1513],{"class":655},[488,1696,660],{"class":659},[488,1698,1518],{"class":659},[488,1700,1701],{"class":490,"line":204},[488,1702,1703],{"class":512},"      for i in {1..30}; do\n",[488,1705,1706,1709,1711,1714,1716,1719,1721,1723,1725],{"class":490,"line":541},[488,1707,1708],{"class":512},"        kubectl get svc -n ingress-nginx ",[488,1710,1531],{"class":659},[488,1712,1713],{"class":655},"helm_release",[488,1715,299],{"class":659},[488,1717,1718],{"class":655},"ingress_nginx",[488,1720,299],{"class":659},[488,1722,1572],{"class":655},[488,1724,1546],{"class":659},[488,1726,1727],{"class":512},"-controller && sleep 30 && break || sleep 30;\n",[488,1729,1730],{"class":490,"line":547},[488,1731,1732],{"class":512},"      done\n",[488,1734,1735],{"class":490,"line":559},[488,1736,1579],{"class":659},[488,1738,1739],{"class":490,"line":573},[488,1740,1348],{"class":655},[488,1742,1743],{"class":490,"line":41},[488,1744,538],{"emptyLinePlaceholder":245},[488,1746,1747,1749,1751,1754,1756],{"class":490,"line":583},[488,1748,1637],{"class":655},[488,1750,660],{"class":659},[488,1752,1753],{"class":655}," [helm_release",[488,1755,299],{"class":659},[488,1757,1758],{"class":655},"ingress_nginx]\n",[488,1760,1761],{"class":490,"line":968},[488,1762,1353],{"class":655},[488,1764,1765],{"class":490,"line":980},[488,1766,538],{"emptyLinePlaceholder":245},[488,1768,1769],{"class":490,"line":988},[488,1770,1771],{"class":493},"# Get external IP using kubectl\n",[488,1773,1774,1777,1780,1783],{"class":490,"line":998},[488,1775,1776],{"class":504},"data",[488,1778,1779],{"class":508}," \"external\"",[488,1781,1782],{"class":508}," \"ingress_external_ip\"",[488,1784,1242],{"class":655},[488,1786,1787,1790,1792,1795,1798,1800,1803,1805],{"class":490,"line":1009},[488,1788,1789],{"class":655},"  program",[488,1791,660],{"class":659},[488,1793,1794],{"class":655}," [",[488,1796,1797],{"class":512},"\"bash\"",[488,1799,427],{"class":655},[488,1801,1802],{"class":512},"\"-c\"",[488,1804,427],{"class":655},[488,1806,1807],{"class":659},"\u003C\u003CEOT\n",[488,1809,1810,1813,1815,1817,1819,1821,1823,1825,1827],{"class":490,"line":1021},[488,1811,1812],{"class":512},"    EXTERNAL_IP=$(kubectl get svc -n ingress-nginx ",[488,1814,1531],{"class":659},[488,1816,1713],{"class":655},[488,1818,299],{"class":659},[488,1820,1718],{"class":655},[488,1822,299],{"class":659},[488,1824,1572],{"class":655},[488,1826,1546],{"class":659},[488,1828,1829],{"class":512},"-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>\u002Fdev\u002Fnull || echo \"\")\n",[488,1831,1832],{"class":490,"line":1033},[488,1833,1834],{"class":512},"    echo \"{\\\"ip\\\":\\\"$EXTERNAL_IP\\\"}\"\n",[488,1836,1837],{"class":490,"line":1043},[488,1838,1839],{"class":659},"  EOT\n",[488,1841,1842],{"class":490,"line":55},[488,1843,1844],{"class":655},"  ]\n",[488,1846,1847],{"class":490,"line":1066},[488,1848,538],{"emptyLinePlaceholder":245},[488,1850,1851,1853,1855,1858,1860],{"class":490,"line":1076},[488,1852,1637],{"class":655},[488,1854,660],{"class":659},[488,1856,1857],{"class":655}," [null_resource",[488,1859,299],{"class":659},[488,1861,1862],{"class":655},"wait_for_ingress_nginx]\n",[488,1864,1865],{"class":490,"line":1088},[488,1866,1353],{"class":655},[617,1868,1870],{"id":1869},"hurdle-the-data-in-our-solution-is-not-safe","Hurdle: The data in our solution is not safe",[231,1872,1873],{},"When using the plausible helm chart, it creates two databases: PostgreSQL and ClickHouse. By default, these databases use ephemeral storage, which means that when the pod is deleted or rescheduled, all data is lost.\nTo make sure our data is safe, we need to use persistent storage. In a cloud environment like Azure, we can use Azure Disks for this.",[231,1875,1876],{},"I've created a module to create or restore an Azure disk and hook it into Kubernetes by creating a persistent volume and persistent volume claim.",[231,1878,1879],{},"This is how you can use the module and link it into your Plausible Helm deployment.",[476,1881,1882,2055],{},[479,1883,1886],{"className":646,"code":1884,"filename":1885,"language":648,"meta":21,"style":21},"module \"create_pv_postgresql\" {\n  source                    = \"..\u002Fpersistent-azure-disk-volume\"\n  snapshot_id               = var.postgresql_restore_snapshot_id\n  azure_location            = var.azure_disk_location\n  pvc_namespace             = var.namespace\n  pv_name                   = \"pv-disk-${var.name}-postgresql-0\"\n  pvc_name                  = \"pvc-disk-${var.name}-postgresql-0\"\n  azure_resource_group_name = var.azure_disk_resource_group_name\n  disk_size_gb              = var.plausible_config_disk_size # Keep this equal to the size defined in the plausible helm chart\n\n  depends_on = [kubernetes_namespace.plausible_analytics]\n}\n","modules\u002Fplausible\u002Fdisks.tf",[443,1887,1888,1898,1909,1925,1940,1954,1978,2001,2015,2033,2037,2051],{"__ignoreMap":21},[488,1889,1890,1893,1896],{"class":490,"line":20},[488,1891,1892],{"class":504},"module",[488,1894,1895],{"class":508}," \"create_pv_postgresql\"",[488,1897,1242],{"class":655},[488,1899,1900,1903,1906],{"class":490,"line":131},[488,1901,1902],{"class":655},"  source",[488,1904,1905],{"class":659},"                    =",[488,1907,1908],{"class":512}," \"..\u002Fpersistent-azure-disk-volume\"\n",[488,1910,1911,1914,1917,1920,1922],{"class":490,"line":155},[488,1912,1913],{"class":655},"  snapshot_id",[488,1915,1916],{"class":659},"               =",[488,1918,1919],{"class":655}," var",[488,1921,299],{"class":659},[488,1923,1924],{"class":655},"postgresql_restore_snapshot_id\n",[488,1926,1927,1930,1933,1935,1937],{"class":490,"line":180},[488,1928,1929],{"class":655},"  azure_location",[488,1931,1932],{"class":659},"            =",[488,1934,1919],{"class":655},[488,1936,299],{"class":659},[488,1938,1939],{"class":655},"azure_disk_location\n",[488,1941,1942,1945,1947,1949,1951],{"class":490,"line":204},[488,1943,1944],{"class":655},"  pvc_namespace",[488,1946,1426],{"class":659},[488,1948,1919],{"class":655},[488,1950,299],{"class":659},[488,1952,1953],{"class":655},"namespace\n",[488,1955,1956,1959,1961,1964,1966,1969,1971,1973,1975],{"class":490,"line":541},[488,1957,1958],{"class":655},"  pv_name",[488,1960,1379],{"class":659},[488,1962,1963],{"class":512}," \"pv-disk-",[488,1965,1531],{"class":659},[488,1967,1968],{"class":655},"var",[488,1970,299],{"class":659},[488,1972,1572],{"class":655},[488,1974,1546],{"class":659},[488,1976,1977],{"class":512},"-postgresql-0\"\n",[488,1979,1980,1983,1986,1989,1991,1993,1995,1997,1999],{"class":490,"line":547},[488,1981,1982],{"class":655},"  pvc_name",[488,1984,1985],{"class":659},"                  =",[488,1987,1988],{"class":512}," \"pvc-disk-",[488,1990,1531],{"class":659},[488,1992,1968],{"class":655},[488,1994,299],{"class":659},[488,1996,1572],{"class":655},[488,1998,1546],{"class":659},[488,2000,1977],{"class":512},[488,2002,2003,2006,2008,2010,2012],{"class":490,"line":559},[488,2004,2005],{"class":655},"  azure_resource_group_name",[488,2007,660],{"class":659},[488,2009,1919],{"class":655},[488,2011,299],{"class":659},[488,2013,2014],{"class":655},"azure_disk_resource_group_name\n",[488,2016,2017,2020,2023,2025,2027,2030],{"class":490,"line":573},[488,2018,2019],{"class":655},"  disk_size_gb",[488,2021,2022],{"class":659},"              =",[488,2024,1919],{"class":655},[488,2026,299],{"class":659},[488,2028,2029],{"class":655},"plausible_config_disk_size ",[488,2031,2032],{"class":493},"# Keep this equal to the size defined in the plausible helm chart\n",[488,2034,2035],{"class":490,"line":41},[488,2036,538],{"emptyLinePlaceholder":245},[488,2038,2039,2041,2043,2046,2048],{"class":490,"line":583},[488,2040,1637],{"class":655},[488,2042,660],{"class":659},[488,2044,2045],{"class":655}," [kubernetes_namespace",[488,2047,299],{"class":659},[488,2049,2050],{"class":655},"plausible_analytics]\n",[488,2052,2053],{"class":490,"line":968},[488,2054,1353],{"class":655},[479,2056,2059],{"className":646,"code":2057,"filename":2058,"language":648,"meta":21,"style":21},"# the existingClaim is set to the pvc_name for both postgresql and clickhouse\npostgresql:\n  primary:\n    persistence:\n      enabled: true\n      existingClaim: pvc-disk-${var.name}-postgresql-0\n      size: ${var.plausible_config_disk_size}Gi # This database is only used for settings and user data, so it doesn't need to be very large\n\n...\n\nclickhouse:\n  persistence:\n    enabled: true\n    existingClaim: pvc-disk-${var.name}-clickhouse-0\n    size: ${var.plausible_data_disk_size}Gi # This database is used for storing all the analytics data, so it needs to be larger\n\n","modules\u002Fplausible\u002Fplausible.tf",[443,2060,2061,2066,2074,2081,2088,2099,2129,2142,2146,2151,2155,2162,2169,2178,2203],{"__ignoreMap":21},[488,2062,2063],{"class":490,"line":20},[488,2064,2065],{"class":493},"# the existingClaim is set to the pvc_name for both postgresql and clickhouse\n",[488,2067,2068,2071],{"class":490,"line":131},[488,2069,2070],{"class":655},"postgresql",[488,2072,2073],{"class":659},":\n",[488,2075,2076,2079],{"class":490,"line":155},[488,2077,2078],{"class":655},"  primary",[488,2080,2073],{"class":659},[488,2082,2083,2086],{"class":490,"line":180},[488,2084,2085],{"class":655},"    persistence",[488,2087,2073],{"class":659},[488,2089,2090,2093,2096],{"class":490,"line":204},[488,2091,2092],{"class":655},"      enabled",[488,2094,2095],{"class":659},":",[488,2097,2098],{"class":508}," true\n",[488,2100,2101,2104,2106,2109,2112,2115,2117,2120,2122,2124,2126],{"class":490,"line":541},[488,2102,2103],{"class":655},"      existingClaim",[488,2105,2095],{"class":659},[488,2107,2108],{"class":655}," pvc",[488,2110,2111],{"class":659},"-",[488,2113,2114],{"class":655},"disk",[488,2116,2111],{"class":659},[488,2118,2119],{"class":655},"${var.name}",[488,2121,2111],{"class":659},[488,2123,2070],{"class":655},[488,2125,2111],{"class":659},[488,2127,2128],{"class":508},"0\n",[488,2130,2131,2134,2136,2139],{"class":490,"line":547},[488,2132,2133],{"class":655},"      size",[488,2135,2095],{"class":659},[488,2137,2138],{"class":655}," ${var.plausible_config_disk_size}Gi ",[488,2140,2141],{"class":493},"# This database is only used for settings and user data, so it doesn't need to be very large\n",[488,2143,2144],{"class":490,"line":559},[488,2145,538],{"emptyLinePlaceholder":245},[488,2147,2148],{"class":490,"line":573},[488,2149,2150],{"class":659},"...\n",[488,2152,2153],{"class":490,"line":41},[488,2154,538],{"emptyLinePlaceholder":245},[488,2156,2157,2160],{"class":490,"line":583},[488,2158,2159],{"class":655},"clickhouse",[488,2161,2073],{"class":659},[488,2163,2164,2167],{"class":490,"line":968},[488,2165,2166],{"class":655},"  persistence",[488,2168,2073],{"class":659},[488,2170,2171,2174,2176],{"class":490,"line":980},[488,2172,2173],{"class":655},"    enabled",[488,2175,2095],{"class":659},[488,2177,2098],{"class":508},[488,2179,2180,2183,2185,2187,2189,2191,2193,2195,2197,2199,2201],{"class":490,"line":988},[488,2181,2182],{"class":655},"    existingClaim",[488,2184,2095],{"class":659},[488,2186,2108],{"class":655},[488,2188,2111],{"class":659},[488,2190,2114],{"class":655},[488,2192,2111],{"class":659},[488,2194,2119],{"class":655},[488,2196,2111],{"class":659},[488,2198,2159],{"class":655},[488,2200,2111],{"class":659},[488,2202,2128],{"class":508},[488,2204,2205,2208,2210,2213],{"class":490,"line":998},[488,2206,2207],{"class":655},"    size",[488,2209,2095],{"class":659},[488,2211,2212],{"class":655}," ${var.plausible_data_disk_size}Gi ",[488,2214,2215],{"class":493},"# This database is used for storing all the analytics data, so it needs to be larger\n",[617,2217,2219],{"id":2218},"hurdle-the-plausible-helm-release-is-not-exposed","Hurdle: The Plausible Helm Release is not exposed",[231,2221,2222],{},"When deploying Plausible via Helm, it doesn't expose the service by default. To make it accessible from the internet, we need to configure an ingress resource.\nWhen configuring the ingress, we can also specify the cert-manager annotation to ensure the certificate is created.",[476,2224,2225],{},[479,2226,2228],{"className":646,"code":2227,"filename":2058,"language":648,"meta":21,"style":21},"ingress:\n  enabled: true\n  annotations:\n    cert-manager.io\u002Fcluster-issuer: \"letsencrypt-production\"\n    kubernetes.io\u002Fingress.class: nginx\n    kubernetes.io\u002Ftls-acme: \"true\"\n  className: nginx\n  hosts:\n    - ${var.plausible_dns}\n  path: \u002F\n  pathType: Prefix\n  tls:\n    - secretName: letsencrypt-production\n      hosts:\n        - ${var.plausible_dns}\n\n",[443,2229,2230,2237,2246,2253,2284,2307,2330,2339,2346,2354,2364,2374,2381,2398,2405],{"__ignoreMap":21},[488,2231,2232,2235],{"class":490,"line":20},[488,2233,2234],{"class":655},"ingress",[488,2236,2073],{"class":659},[488,2238,2239,2242,2244],{"class":490,"line":131},[488,2240,2241],{"class":655},"  enabled",[488,2243,2095],{"class":659},[488,2245,2098],{"class":508},[488,2247,2248,2251],{"class":490,"line":155},[488,2249,2250],{"class":655},"  annotations",[488,2252,2073],{"class":659},[488,2254,2255,2258,2260,2263,2265,2268,2271,2274,2276,2279,2281],{"class":490,"line":180},[488,2256,2257],{"class":655},"    cert",[488,2259,2111],{"class":659},[488,2261,2262],{"class":655},"manager",[488,2264,299],{"class":659},[488,2266,2267],{"class":655},"io",[488,2269,2270],{"class":659},"\u002F",[488,2272,2273],{"class":655},"cluster",[488,2275,2111],{"class":659},[488,2277,2278],{"class":655},"issuer",[488,2280,2095],{"class":659},[488,2282,2283],{"class":512}," \"letsencrypt-production\"\n",[488,2285,2286,2289,2291,2293,2295,2297,2299,2302,2304],{"class":490,"line":204},[488,2287,2288],{"class":655},"    kubernetes",[488,2290,299],{"class":659},[488,2292,2267],{"class":655},[488,2294,2270],{"class":659},[488,2296,2234],{"class":655},[488,2298,299],{"class":659},[488,2300,2301],{"class":655},"class",[488,2303,2095],{"class":659},[488,2305,2306],{"class":655}," nginx\n",[488,2308,2309,2311,2313,2315,2317,2320,2322,2325,2327],{"class":490,"line":541},[488,2310,2288],{"class":655},[488,2312,299],{"class":659},[488,2314,2267],{"class":655},[488,2316,2270],{"class":659},[488,2318,2319],{"class":655},"tls",[488,2321,2111],{"class":659},[488,2323,2324],{"class":655},"acme",[488,2326,2095],{"class":659},[488,2328,2329],{"class":512}," \"true\"\n",[488,2331,2332,2335,2337],{"class":490,"line":547},[488,2333,2334],{"class":655},"  className",[488,2336,2095],{"class":659},[488,2338,2306],{"class":655},[488,2340,2341,2344],{"class":490,"line":559},[488,2342,2343],{"class":655},"  hosts",[488,2345,2073],{"class":659},[488,2347,2348,2351],{"class":490,"line":573},[488,2349,2350],{"class":659},"    -",[488,2352,2353],{"class":655}," ${var.plausible_dns}\n",[488,2355,2356,2359,2361],{"class":490,"line":41},[488,2357,2358],{"class":655},"  path",[488,2360,2095],{"class":659},[488,2362,2363],{"class":659}," \u002F\n",[488,2365,2366,2369,2371],{"class":490,"line":583},[488,2367,2368],{"class":655},"  pathType",[488,2370,2095],{"class":659},[488,2372,2373],{"class":655}," Prefix\n",[488,2375,2376,2379],{"class":490,"line":968},[488,2377,2378],{"class":655},"  tls",[488,2380,2073],{"class":659},[488,2382,2383,2385,2388,2390,2393,2395],{"class":490,"line":980},[488,2384,2350],{"class":659},[488,2386,2387],{"class":655}," secretName",[488,2389,2095],{"class":659},[488,2391,2392],{"class":655}," letsencrypt",[488,2394,2111],{"class":659},[488,2396,2397],{"class":655},"production\n",[488,2399,2400,2403],{"class":490,"line":988},[488,2401,2402],{"class":655},"      hosts",[488,2404,2073],{"class":659},[488,2406,2407,2410],{"class":490,"line":998},[488,2408,2409],{"class":659},"        -",[488,2411,2353],{"class":655},[617,2413,2415],{"id":2414},"hurdle-cant-restore-plausible-from-a-backup-when-the-environment-already-exists","Hurdle: Can't restore plausible from a backup when the environment already exists",[231,2417,2418],{},"When changing the snapshot IDs in the terraform.tfvars file, Terraform doesn't recreate the plausible helm release or persistent volume claims, because it doesn't see any changes in them.\nThis prevents the deletion & recreation of the azure disks and persistent volumes, because they're never unbound.\nI therefore added a null_resource that triggers a replacement of the plausible release and the persistent volume claims when the snapshot IDs change.\nThis way you can specify new snapshot IDs and have the resources recreated without manual intervention.",[476,2420,2421,2517],{},[479,2422,2424],{"className":646,"code":2423,"filename":2058,"language":648,"meta":21,"style":21},"resource \"null_resource\" \"snapshot_trigger\" {\n  triggers = {\n    postgresql_snapshot = var.postgresql_restore_snapshot_id\n    clickhouse_snapshot = var.clickhouse_restore_snapshot_id\n  }\n}\n...\n\n  lifecycle {\n    replace_triggered_by = [\n      null_resource.snapshot_trigger\n    ]\n  }\n",[443,2425,2426,2437,2445,2455,2465,2469,2473,2477,2481,2488,2498,2508,2513],{"__ignoreMap":21},[488,2427,2428,2430,2432,2435],{"class":490,"line":20},[488,2429,1490],{"class":504},[488,2431,1493],{"class":508},[488,2433,2434],{"class":508}," \"snapshot_trigger\"",[488,2436,1242],{"class":655},[488,2438,2439,2441,2443],{"class":490,"line":131},[488,2440,1597],{"class":655},[488,2442,660],{"class":659},[488,2444,1242],{"class":655},[488,2446,2447,2450,2452],{"class":490,"line":155},[488,2448,2449],{"class":655},"    postgresql_snapshot ",[488,2451,1264],{"class":659},[488,2453,2454],{"class":655}," var.postgresql_restore_snapshot_id\n",[488,2456,2457,2460,2462],{"class":490,"line":180},[488,2458,2459],{"class":655},"    clickhouse_snapshot ",[488,2461,1264],{"class":659},[488,2463,2464],{"class":655}," var.clickhouse_restore_snapshot_id\n",[488,2466,2467],{"class":490,"line":204},[488,2468,1348],{"class":655},[488,2470,2471],{"class":490,"line":541},[488,2472,1353],{"class":655},[488,2474,2475],{"class":490,"line":547},[488,2476,2150],{"class":659},[488,2478,2479],{"class":490,"line":559},[488,2480,538],{"emptyLinePlaceholder":245},[488,2482,2483,2486],{"class":490,"line":573},[488,2484,2485],{"class":504},"  lifecycle",[488,2487,1242],{"class":655},[488,2489,2490,2493,2495],{"class":490,"line":41},[488,2491,2492],{"class":655},"    replace_triggered_by",[488,2494,660],{"class":659},[488,2496,2497],{"class":655}," [\n",[488,2499,2500,2503,2505],{"class":490,"line":583},[488,2501,2502],{"class":655},"      null_resource",[488,2504,299],{"class":659},[488,2506,2507],{"class":655},"snapshot_trigger\n",[488,2509,2510],{"class":490,"line":968},[488,2511,2512],{"class":655},"    ]\n",[488,2514,2515],{"class":490,"line":980},[488,2516,1348],{"class":655},[479,2518,2521],{"className":646,"code":2519,"filename":2520,"language":648,"meta":21,"style":21},"resource \"null_resource\" \"snapshot_trigger\" {\n  triggers = {\n    snapshot = var.snapshot_id\n  }\n}\n...\n\n  lifecycle {\n    replace_triggered_by = [\n      null_resource.snapshot_trigger\n    ]\n  }\n","modules\u002Fpersistent-...\u002Fpersistent-azure-disk-volume.tf",[443,2522,2523,2533,2541,2551,2555,2559,2563,2567,2573,2581,2589,2593],{"__ignoreMap":21},[488,2524,2525,2527,2529,2531],{"class":490,"line":20},[488,2526,1490],{"class":504},[488,2528,1493],{"class":508},[488,2530,2434],{"class":508},[488,2532,1242],{"class":655},[488,2534,2535,2537,2539],{"class":490,"line":131},[488,2536,1597],{"class":655},[488,2538,660],{"class":659},[488,2540,1242],{"class":655},[488,2542,2543,2546,2548],{"class":490,"line":155},[488,2544,2545],{"class":655},"    snapshot ",[488,2547,1264],{"class":659},[488,2549,2550],{"class":655}," var.snapshot_id\n",[488,2552,2553],{"class":490,"line":180},[488,2554,1348],{"class":655},[488,2556,2557],{"class":490,"line":204},[488,2558,1353],{"class":655},[488,2560,2561],{"class":490,"line":541},[488,2562,2150],{"class":659},[488,2564,2565],{"class":490,"line":547},[488,2566,538],{"emptyLinePlaceholder":245},[488,2568,2569,2571],{"class":490,"line":559},[488,2570,2485],{"class":504},[488,2572,1242],{"class":655},[488,2574,2575,2577,2579],{"class":490,"line":573},[488,2576,2492],{"class":655},[488,2578,660],{"class":659},[488,2580,2497],{"class":655},[488,2582,2583,2585,2587],{"class":490,"line":41},[488,2584,2502],{"class":655},[488,2586,299],{"class":659},[488,2588,2507],{"class":655},[488,2590,2591],{"class":490,"line":583},[488,2592,2512],{"class":655},[488,2594,2595],{"class":490,"line":968},[488,2596,1348],{"class":655},[617,2598,313],{"id":2599},"hurdle-memory-shortage-for-clickhouse-container",[231,2601,2602],{},"The ClickHouse container was using too much memory, causing the Plausible solution to crash every couple of hours.\nThis was due to ClickHouse's internal configuration, it looks at the available memory and automatically sets its own limits to 75% of it.",[231,2604,2605],{},"The problem was that ClickHouse was looking at the entire node's memory (4GB) and trying to use 75% of that (3GB), which caused memory issues for the other pods running on the same node.",[231,2607,2608,2611],{},[296,2609,2610],{},"The solution:"," Set a Kubernetes memory limit for the ClickHouse container. When Kubernetes sets a memory limit, this is passed to the container through cgroups. ClickHouse reads this cgroup limit instead of the node's total memory, and calculates 75% of the container limit rather than 75% of the node's memory.",[231,2613,2614],{},"By setting a limit of 1229Mi (approximately 1.2Gi), ClickHouse now sees only this amount and uses 75% of 1229Mi (≈922Mi), which keeps it well within acceptable bounds while leaving enough memory for other pods.",[476,2616,2617],{},[479,2618,2621],{"className":2619,"code":2620,"filename":2058,"language":277,"meta":21,"style":21},"language-yaml shiki shiki-themes github-light github-dark","clickhouse:\n  resources:\n    limits:\n      memory: 1229Mi  # 1.2Gi max to stay within node capacity\n",[443,2622,2623,2628,2633,2638],{"__ignoreMap":21},[488,2624,2625],{"class":490,"line":20},[488,2626,2627],{},"clickhouse:\n",[488,2629,2630],{"class":490,"line":131},[488,2631,2632],{},"  resources:\n",[488,2634,2635],{"class":490,"line":155},[488,2636,2637],{},"    limits:\n",[488,2639,2640],{"class":490,"line":180},[488,2641,2642],{},"      memory: 1229Mi  # 1.2Gi max to stay within node capacity\n",[617,2644,318],{"id":2645},"hurdle-disk-full-due-to-system-logs",[231,2647,2648,2649,2652],{},"After running Plausible for a few months, I discovered that the ClickHouse database had consumed ",[296,2650,2651],{},"7.5GB of disk space"," just for system logs. This quickly filled up the allocated disk and caused the containers to crash.",[231,2654,2655],{},"ClickHouse by default enables several heavy system logs:",[355,2657,2658,2664,2670],{},[358,2659,2660,2663],{},[296,2661,2662],{},"trace_log",": Was using 2.6GB",[358,2665,2666,2669],{},[296,2667,2668],{},"metric_log",": Was using 1.9GB",[358,2671,2672,2669],{},[296,2673,2674],{},"asynchronous_metric_log",[231,2676,2677],{},"For a small analytics setup like ours, these detailed system logs are unnecessary and wasteful.",[231,2679,2680,2682,2683,2686],{},[296,2681,2610],{}," Disable the heavy system logs entirely and keep only essential logs with short retention periods. I used the ",[443,2684,2685],{},"extraOverrides"," configuration in the ClickHouse Helm chart to inject custom XML configuration:",[476,2688,2689],{},[479,2690,2692],{"className":2619,"code":2691,"filename":2058,"language":277,"meta":21,"style":21},"clickhouse:\n  logLevel: warning\n  extraOverrides: |\n    \u003Cclickhouse>\n      \u003C!-- Disable trace_log (was using 2.6GB) -->\n      \u003Ctrace_log remove=\"1\"\u002F>\n      \u003C!-- Disable metric_log (was using 1.9GB) -->\n      \u003Cmetric_log remove=\"1\"\u002F>\n      \u003C!-- Disable asynchronous_metric_log (was using 1.9GB) -->\n      \u003Casynchronous_metric_log remove=\"1\"\u002F>\n      \u003C!-- Keep query_log but with short 7-day retention -->\n      \u003Cquery_log>\n        \u003Cdatabase>system\u003C\u002Fdatabase>\n        \u003Ctable>query_log\u003C\u002Ftable>\n        \u003Cpartition_by>toYYYYMM(event_date)\u003C\u002Fpartition_by>\n        \u003Cttl>event_date + INTERVAL 7 DAY\u003C\u002Fttl>\n      \u003C\u002Fquery_log>\n      \u003C!-- Keep text_log but with short 3-day retention -->\n      \u003Ctext_log>\n        \u003Cdatabase>system\u003C\u002Fdatabase>\n        \u003Ctable>text_log\u003C\u002Ftable>\n        \u003Cttl>event_date + INTERVAL 3 DAY\u003C\u002Fttl>\n      \u003C\u002Ftext_log>\n    \u003C\u002Fclickhouse>\n",[443,2693,2694,2698,2703,2708,2713,2718,2723,2728,2733,2738,2743,2748,2753,2758,2763,2768,2773,2778,2783,2788,2792,2797,2802,2807],{"__ignoreMap":21},[488,2695,2696],{"class":490,"line":20},[488,2697,2627],{},[488,2699,2700],{"class":490,"line":131},[488,2701,2702],{},"  logLevel: warning\n",[488,2704,2705],{"class":490,"line":155},[488,2706,2707],{},"  extraOverrides: |\n",[488,2709,2710],{"class":490,"line":180},[488,2711,2712],{},"    \u003Cclickhouse>\n",[488,2714,2715],{"class":490,"line":204},[488,2716,2717],{},"      \u003C!-- Disable trace_log (was using 2.6GB) -->\n",[488,2719,2720],{"class":490,"line":541},[488,2721,2722],{},"      \u003Ctrace_log remove=\"1\"\u002F>\n",[488,2724,2725],{"class":490,"line":547},[488,2726,2727],{},"      \u003C!-- Disable metric_log (was using 1.9GB) -->\n",[488,2729,2730],{"class":490,"line":559},[488,2731,2732],{},"      \u003Cmetric_log remove=\"1\"\u002F>\n",[488,2734,2735],{"class":490,"line":573},[488,2736,2737],{},"      \u003C!-- Disable asynchronous_metric_log (was using 1.9GB) -->\n",[488,2739,2740],{"class":490,"line":41},[488,2741,2742],{},"      \u003Casynchronous_metric_log remove=\"1\"\u002F>\n",[488,2744,2745],{"class":490,"line":583},[488,2746,2747],{},"      \u003C!-- Keep query_log but with short 7-day retention -->\n",[488,2749,2750],{"class":490,"line":968},[488,2751,2752],{},"      \u003Cquery_log>\n",[488,2754,2755],{"class":490,"line":980},[488,2756,2757],{},"        \u003Cdatabase>system\u003C\u002Fdatabase>\n",[488,2759,2760],{"class":490,"line":988},[488,2761,2762],{},"        \u003Ctable>query_log\u003C\u002Ftable>\n",[488,2764,2765],{"class":490,"line":998},[488,2766,2767],{},"        \u003Cpartition_by>toYYYYMM(event_date)\u003C\u002Fpartition_by>\n",[488,2769,2770],{"class":490,"line":1009},[488,2771,2772],{},"        \u003Cttl>event_date + INTERVAL 7 DAY\u003C\u002Fttl>\n",[488,2774,2775],{"class":490,"line":1021},[488,2776,2777],{},"      \u003C\u002Fquery_log>\n",[488,2779,2780],{"class":490,"line":1033},[488,2781,2782],{},"      \u003C!-- Keep text_log but with short 3-day retention -->\n",[488,2784,2785],{"class":490,"line":1043},[488,2786,2787],{},"      \u003Ctext_log>\n",[488,2789,2790],{"class":490,"line":55},[488,2791,2757],{},[488,2793,2794],{"class":490,"line":1066},[488,2795,2796],{},"        \u003Ctable>text_log\u003C\u002Ftable>\n",[488,2798,2799],{"class":490,"line":1076},[488,2800,2801],{},"        \u003Cttl>event_date + INTERVAL 3 DAY\u003C\u002Fttl>\n",[488,2803,2804],{"class":490,"line":1088},[488,2805,2806],{},"      \u003C\u002Ftext_log>\n",[488,2808,2809],{"class":490,"line":1099},[488,2810,2811],{},"    \u003C\u002Fclickhouse>\n",[231,2813,2814],{},"This configuration dramatically reduced disk usage while still maintaining the essential query logs (with 7-day retention) and text logs (with 3-day retention) for debugging purposes when needed.",[617,2816,2818],{"id":2817},"hurdle-unnecessary-costs-in-our-aks-cluster","Hurdle: unnecessary costs in our AKS cluster",[231,2820,2821,2822,2824],{},"In my previous article ",[310,2823,128],{"href":133},", you learned a few tricks to reduce the costs of your AKS cluster. These are incorporated in this solution as well.",[355,2826,2827,2836,2842],{},[358,2828,2829,2832,2833,299],{},[296,2830,2831],{},"Use ephemeral disks",": These are stored directly on the VM's local storage and come at ",[296,2834,2835],{},"no additional cost",[358,2837,2838,2841],{},[296,2839,2840],{},"Standard_B2s configuration",": The most cost-effective VM configuration available",[358,2843,2844,2847],{},[296,2845,2846],{},"Increase the number of pods per node",": To allow more workloads on the Standard_B2s instance",[350,2849,2851],{"id":2850},"final-thoughts","Final Thoughts",[231,2853,2854,2855,2857],{},"We've successfully transformed the bash scripts from our ",[310,2856,325],{"href":133}," into a production-grade Kubernetes deployment on Azure.\nBy leveraging Terraform's declarative approach and AKS's managed infrastructure, you now have a Plausible Analytics\ninstance that's not just running—it's scalable, maintainable, and ready for real-world traffic.",[231,2859,2860,2861,2863],{},"The beauty of this Infrastructure as Code approach is in its repeatability. Need a staging environment?\nJust duplicate the ",[296,2862,1155],{}," folder with different variables.\nWant to deploy to another region? Change a single parameter.\nEvery infrastructure decision is documented in code, reviewed through pull requests, and rolled back if needed.",[231,2865,2866],{},"While this setup might seem like overkill for a simple analytics tool, the patterns you've learned here (modularized Terraform,\ncert-manager integration, proper secret management) will serve you well for any production Kubernetes workload.",[2868,2869,2870],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":21,"searchDepth":131,"depth":131,"links":2872},[2873,2874,2875,2878,2879,2880,2890],{"id":352,"depth":131,"text":353},{"id":419,"depth":131,"text":420},{"id":461,"depth":131,"text":462,"children":2876},[2877],{"id":619,"depth":155,"text":620},{"id":790,"depth":131,"text":791},{"id":829,"depth":131,"text":830},{"id":848,"depth":131,"text":849,"children":2881},[2882,2883,2884,2885,2886,2887,2888,2889],{"id":1211,"depth":155,"text":1212},{"id":1654,"depth":155,"text":1655},{"id":1869,"depth":155,"text":1870},{"id":2218,"depth":155,"text":2219},{"id":2414,"depth":155,"text":2415},{"id":2599,"depth":155,"text":313},{"id":2645,"depth":155,"text":318},{"id":2817,"depth":155,"text":2818},{"id":2850,"depth":131,"text":2851},"2025-09-20T16:34:55","Learn how to build a cost-optimized, fully automated Kubernetes infrastructure on Azure using Terraform, complete with HTTPS certificates and backup strategies",{"type":228,"value":2894},[2895],[231,2896,294,2897,299],{},[296,2898,298],{},"Plausible.io, Terraform, Kubernetes and Azure logos","object-top","\u002Fposts\u002F4\u002Fcover.jpeg",[2903,2904,333],"Kubernetes","Azure",{},{"text":2907,"minutes":2908,"time":2909,"words":2910},"13 min read",12.31,738600,2462,{"title":177,"description":2892},"CqlefQTYevh0YgRCooPLGxXYNZWy4kPKC3SftqPDMVg",{"id":272,"company":2914,"extension":277,"fullName":278,"github":279,"homePage":280,"imageUrl":276,"linkedIn":281,"meta":2915,"role":283,"stem":284,"twitter":285,"userName":286,"__hash__":287},{"name":274,"url":275,"imageUrl":276},{},1781641709303]