<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Infrastructure on PHP Boy Scout</title><link>https://blog-570662.gitlab.io/categories/infrastructure/</link><description>Recent content in Infrastructure on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 17 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog-570662.gitlab.io/categories/infrastructure/index.xml" rel="self" type="application/rss+xml"/><item><title>One graph, not micro-stacks</title><link>https://blog-570662.gitlab.io/one-graph-not-micro-stacks/</link><pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/one-graph-not-micro-stacks/</guid><description>&lt;img src="https://blog-570662.gitlab.io/one-graph-not-micro-stacks/cover-one-graph-not-micro-stacks.png" alt="Featured image of post One graph, not micro-stacks" /&gt;&lt;p&gt;Once an infrastructure repo has a few concerns in it (account hardening, the security baseline, the signing stack still to come) there&amp;rsquo;s a steady pressure to split them into separate stacks with separate state, and Terragrunt is right there to help you do it. The &lt;code&gt;infra&lt;/code&gt; repo keeps everything in one OpenTofu graph instead. The reason comes down to who enforces your dependency ordering: the engine, or you.&lt;/p&gt;
&lt;h2 id="the-pressure-to-split"&gt;The pressure to split
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; repo&amp;rsquo;s &lt;code&gt;src/&lt;/code&gt; has several concerns in it, and more coming, the signing stack among them. Once a repo reaches that point, there&amp;rsquo;s a steady pressure to split: one stack per concern, each with its own state file.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s an appealing pressure. Separate stacks feel modular. Each &lt;code&gt;apply&lt;/code&gt; touches less, so the blast radius of any one run is smaller. And Terragrunt exists, popular and well-regarded, precisely to orchestrate a fleet of separate stacks. The path is well trodden.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; didn&amp;rsquo;t take it. &lt;code&gt;src/&lt;/code&gt; is a single OpenTofu root stack: each concern is a &lt;code&gt;module&lt;/code&gt; block, in its own &lt;code&gt;main.&amp;lt;concern&amp;gt;.tf&lt;/code&gt; file, all sharing one state and one graph.&lt;/p&gt;
&lt;h2 id="what-one-graph-gives-you"&gt;What one graph gives you
&lt;/h2&gt;&lt;p&gt;The thing a single graph gives you is engine-enforced truth about ordering and data.&lt;/p&gt;
&lt;p&gt;Inside one OpenTofu graph, the tool builds the full dependency DAG itself. When the signing stack needs a value the security baseline produced, you reference it directly, &lt;code&gt;module.baseline.something&lt;/code&gt;, and OpenTofu &lt;em&gt;guarantees&lt;/em&gt; two things: the baseline is created before the thing that depends on it, and the value handed across is the current one from this same apply. Ordering and data-passing aren&amp;rsquo;t things you arranged. They&amp;rsquo;re facts the engine checks and enforces, every plan, every apply.&lt;/p&gt;
&lt;h2 id="what-splitting-costs"&gt;What splitting costs
&lt;/h2&gt;&lt;p&gt;Split &lt;code&gt;src/&lt;/code&gt; into per-concern stacks with separate state, and that guarantee is the thing you spend.&lt;/p&gt;
&lt;p&gt;Now one stack reads another&amp;rsquo;s outputs through &lt;code&gt;terraform_remote_state&lt;/code&gt;. That&amp;rsquo;s a lookup of a &lt;em&gt;snapshot&lt;/em&gt;: the other stack&amp;rsquo;s last applied state, whatever it was, whenever that was. It&amp;rsquo;s not a live edge in a graph. Ordering is no longer enforced by the engine either; it becomes something you arrange yourself, in CI stage sequencing or in Terragrunt&amp;rsquo;s own dependency blocks.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the trade, stated plainly. You give up a strong, engine-checked guarantee, and you buy back a weaker, hand-arranged imitation of it. Terragrunt is a good tool for managing that weaker world tidily. But the question worth asking first is whether you should be in the weaker world at all.&lt;/p&gt;
&lt;h2 id="when-splitting-is-genuinely-right"&gt;When splitting is genuinely right
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that splitting is always wrong. Separate states genuinely earn their place when concerns have different change cadences, different access boundaries, or different teams owning them: when you actively &lt;em&gt;want&lt;/em&gt; an apply of one to be unable to touch another, and you want different people holding different state.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; has none of those. It&amp;rsquo;s a single account, a single operator, one cohesive set of concerns. The only thing splitting would buy here is a smaller per-apply blast radius, and that&amp;rsquo;s better handled by reviewing the plan before it applies, which &lt;a class="link" href="https://blog-570662.gitlab.io/reviewed-then-applied/" &gt;the next post&lt;/a&gt; is about, than by fragmenting the dependency graph. So &lt;code&gt;src/&lt;/code&gt; stays one graph, and Terragrunt was considered and deliberately not adopted.&lt;/p&gt;
&lt;h2 id="if-ordering-between-graphs-is-ever-needed"&gt;If ordering between graphs is ever needed
&lt;/h2&gt;&lt;p&gt;If &lt;code&gt;infra&lt;/code&gt; ever does genuinely need more than one stack, the plan isn&amp;rsquo;t Terragrunt. It&amp;rsquo;s to keep each stack a single strong graph internally, and to sequence the stacks with CI stages. Keep the engine-enforced guarantee where it&amp;rsquo;s strongest, inside each graph, and reach for hand-arranged ordering only at the one seam where it&amp;rsquo;s unavoidable.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;A multi-concern infrastructure repo feels like it should be split into per-concern stacks, and Terragrunt is right there to manage the result. &lt;code&gt;infra&lt;/code&gt; keeps &lt;code&gt;src/&lt;/code&gt; as one OpenTofu graph instead.&lt;/p&gt;
&lt;p&gt;Inside one graph, OpenTofu enforces dependency ordering and passes current values across module boundaries as checked facts. Split into separate states and that becomes a &lt;code&gt;terraform_remote_state&lt;/code&gt; snapshot lookup plus ordering you arrange by hand: a weaker version of what you gave up. Splitting is right when concerns have different cadences, boundaries or owners; for a single-account, single-operator repo none of that applies, so the strong guarantee is worth keeping, and Terragrunt is the tool for a problem &lt;code&gt;infra&lt;/code&gt; chose not to have.&lt;/p&gt;</description></item><item><title>Routing security findings without the noise</title><link>https://blog-570662.gitlab.io/routing-security-findings-without-the-noise/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/routing-security-findings-without-the-noise/</guid><description>&lt;img src="https://blog-570662.gitlab.io/routing-security-findings-without-the-noise/cover-routing-security-findings-without-the-noise.png" alt="Featured image of post Routing security findings without the noise" /&gt;&lt;p&gt;Turning on GuardDuty and Security Hub gives you threat detection. It also gives you a firehose. And an alert system that dutifully forwards everything in that firehose isn&amp;rsquo;t monitoring, it&amp;rsquo;s a very efficient way of training your team to ignore alerts. So the &lt;code&gt;alerts&lt;/code&gt; module&amp;rsquo;s real job isn&amp;rsquo;t detection at all. It&amp;rsquo;s deciding what&amp;rsquo;s actually worth interrupting a human for, and the interesting part is everything it deliberately throws away.&lt;/p&gt;
&lt;h2 id="detection-is-the-easy-half"&gt;Detection is the easy half
&lt;/h2&gt;&lt;p&gt;Switching on threat detection in an AWS account is a few resources. GuardDuty, Security Hub with its standards, IAM Access Analyzer: &lt;a class="link" href="https://blog-570662.gitlab.io/hardening-the-account-that-will-hold-the-keys/" &gt;the security baseline&lt;/a&gt; does exactly that. From then on, the account is generating findings.&lt;/p&gt;
&lt;p&gt;And it generates a lot of them. Plenty are low-severity, informational, or simply the normal texture of a cloud account. If you wire every finding to an email or a pager, you haven&amp;rsquo;t built monitoring. You&amp;rsquo;ve built noise. And noise has a specific failure mode: people stop reading it, and the one finding that genuinely mattered scrolls past unread alongside two hundred that didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;So the valuable work isn&amp;rsquo;t detection. It&amp;rsquo;s &lt;em&gt;routing&lt;/em&gt;: deciding what&amp;rsquo;s worth interrupting a human for, and letting the rest sit quietly in a console for whenever someone reviews it.&lt;/p&gt;
&lt;h2 id="forward-the-severe-leave-the-rest"&gt;Forward the severe, leave the rest
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;alerts&lt;/code&gt; module routes findings with EventBridge rules into an SNS topic that emails out. The rules are deliberately picky. GuardDuty findings are forwarded only at severity 7 and above. Security Hub findings are forwarded only at HIGH and CRITICAL.&lt;/p&gt;
&lt;p&gt;Everything below those thresholds isn&amp;rsquo;t discarded. It&amp;rsquo;s still in GuardDuty and Security Hub, where someone doing a review will see it. It just doesn&amp;rsquo;t get to interrupt anyone&amp;rsquo;s day. The threshold is the line between &amp;ldquo;look at this now&amp;rdquo; and &amp;ldquo;look at this sometime&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="the-duplicate-you-would-otherwise-send-twice"&gt;The duplicate you would otherwise send twice
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the subtle one, and it&amp;rsquo;s the kind of thing you only find by looking closely at where findings come from.&lt;/p&gt;
&lt;p&gt;Security Hub is an aggregator. It pulls findings &lt;em&gt;in&lt;/em&gt; from other services, GuardDuty among them. So a single GuardDuty finding can show up in two places: in GuardDuty itself, and again in Security Hub as an aggregated copy.&lt;/p&gt;
&lt;p&gt;A rule on GuardDuty findings and a rule on Security Hub HIGH/CRITICAL findings would therefore both fire for the same underlying GuardDuty finding. One event, two emails. Do that across an account and a meaningful fraction of your alert volume is just the same findings counted twice, which is its own kind of noise.&lt;/p&gt;
&lt;p&gt;So the Security Hub rule explicitly excludes findings whose &lt;code&gt;ProductName&lt;/code&gt; is GuardDuty, with an &lt;code&gt;anything-but&lt;/code&gt; match. GuardDuty findings come through the GuardDuty rule. The Security Hub rule handles everything Security Hub adds that GuardDuty didn&amp;rsquo;t already report. One finding, one alert, regardless of how many services it passed through.&lt;/p&gt;
&lt;h2 id="two-tripwires-on-the-root-account"&gt;Two tripwires on the root account
&lt;/h2&gt;&lt;p&gt;Findings are about threats the detectors recognise. The module adds two alarms about something simpler: the root account doing anything at all.&lt;/p&gt;
&lt;p&gt;One CloudWatch alarm fires on a root console sign-in. The other fires on any root API call that isn&amp;rsquo;t a console login. In a well-run AWS account, the root user does almost nothing after initial setup: day-to-day work happens through roles. So root activity isn&amp;rsquo;t a &amp;ldquo;finding&amp;rdquo; to be assessed for severity. It&amp;rsquo;s a tripwire. Any of it, in an account that should be silent, is worth an immediate look, and the two alarms say so directly.&lt;/p&gt;
&lt;h2 id="why-a-quiet-alert-stream-matters-here"&gt;Why a quiet alert stream matters here
&lt;/h2&gt;&lt;p&gt;This is monitoring for the account that&amp;rsquo;s going to hold the release-signing key, and that raises the stakes on getting the routing right.&lt;/p&gt;
&lt;p&gt;If a key-bearing account ever does come under attack, the alert that says so has to be &lt;em&gt;seen&lt;/em&gt;. An alert stream that&amp;rsquo;s mostly noise and duplicates is, functionally, no alerting at all, because the people who&amp;rsquo;d act on it have long since tuned it out. Routing the stream down to &amp;ldquo;severe, deduplicated, plus root tripwires&amp;rdquo; is what keeps it something a human will still read on the day it finally matters.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;GuardDuty and Security Hub make detection easy. The hard, valuable part is routing: forwarding what deserves to interrupt someone and leaving the rest in a console.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;alerts&lt;/code&gt; module forwards GuardDuty at severity 7-plus and Security Hub at HIGH/CRITICAL, and it drops the duplicate that aggregation creates by excluding GuardDuty-sourced findings from the Security Hub rule, so one finding is one alert. Two CloudWatch alarms act as tripwires on root-account activity, which should be near-zero. For the account that will hold the signing key, a quiet, trustworthy alert stream isn&amp;rsquo;t a nicety. It&amp;rsquo;s the difference between monitoring and theatre.&lt;/p&gt;</description></item><item><title>Why go-tool-base left GitHub for GitLab</title><link>https://blog-570662.gitlab.io/why-we-left-github-for-gitlab/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/why-we-left-github-for-gitlab/</guid><description>&lt;img src="https://blog-570662.gitlab.io/why-we-left-github-for-gitlab/cover-why-we-left-github-for-gitlab.png" alt="Featured image of post Why go-tool-base left GitHub for GitLab" /&gt;&lt;p&gt;A botched version bump made me stop and actually look at where go-tool-base lived, and I didn&amp;rsquo;t much like what I saw. GitHub had spent months quietly falling over, and when Mitchell Hashimoto (GitHub user #1299, no less) publicly walked Ghostty off the platform, it stopped feeling like just my problem. I&amp;rsquo;ve been a GitLab fan for years, so the move was less a leap and more an overdue nudge. This is the &lt;em&gt;why&lt;/em&gt;, not the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="it-started-with-a-wrong-number"&gt;It started with a wrong number
&lt;/h2&gt;&lt;p&gt;Every migration has a trigger, and mine was embarrassingly small. A commit landed on &lt;code&gt;main&lt;/code&gt; carrying a &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; footer it didn&amp;rsquo;t really deserve. Semantic-release did exactly what it&amp;rsquo;s told to do with that footer: it cut a major version. go-tool-base lurched from the v1 line straight to v2.0.0, and a chain of things that keyed off the version went sideways with it.&lt;/p&gt;
&lt;p&gt;It was fixable. It wasn&amp;rsquo;t a disaster. But it was the kind of small, stupid breakage that makes you stop and actually &lt;em&gt;look&lt;/em&gt; at your setup instead of just patching it and moving on. And when I looked, the version bump wasn&amp;rsquo;t the thing that bothered me. It was everything around it.&lt;/p&gt;
&lt;h2 id="the-platform-had-been-quietly-failing"&gt;The platform had been quietly failing
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d been losing time to GitHub for months. Not dramatically. No single outage you&amp;rsquo;d write home about, just a steady drip of Actions queues that wouldn&amp;rsquo;t drain, pull requests that wouldn&amp;rsquo;t merge, the occasional morning where the thing simply wasn&amp;rsquo;t there. You absorb it. You re-run the job. You make a coffee and try again. You tell yourself it&amp;rsquo;s a blip.&lt;/p&gt;
&lt;p&gt;The trouble with a steady drip is that you stop counting it. It becomes weather.&lt;/p&gt;
&lt;h2 id="the-canary-left-the-mine"&gt;The canary left the mine
&lt;/h2&gt;&lt;p&gt;Then, in late April, &lt;a class="link" href="https://mitchellh.com/" target="_blank" rel="noopener"
 &gt;Mitchell Hashimoto&lt;/a&gt; (co-founder of HashiCorp, creator of Vagrant, Terraform and the Ghostty terminal) published &lt;a class="link" href="https://mitchellh.com/writing/ghostty-leaving-github" target="_blank" rel="noopener"
 &gt;&lt;em&gt;Ghostty Is Leaving GitHub&lt;/em&gt;&lt;/a&gt;, and &lt;em&gt;The Register&lt;/em&gt; &lt;a class="link" href="https://www.theregister.com/software/2026/04/29/mitchell-hashimoto-says-github-no-longer-for-serious-work/5227505" target="_blank" rel="noopener"
 &gt;picked it up&lt;/a&gt; a day later under the headline &amp;ldquo;GitHub &amp;rsquo;no longer a place for serious work&amp;rsquo;&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;This is not a man with a casual relationship to GitHub. He&amp;rsquo;s, by his own account, user #1299, joined February 2008. He called it &amp;ldquo;the place that has made me the most happy&amp;rdquo;. And he still wrote this:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;This is no longer a place for serious work if it just blocks you out for hours per day, every day.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;The detail that landed hardest for me wasn&amp;rsquo;t a quote, it was a habit. He&amp;rsquo;d kept a journal for a month, marking an &amp;ldquo;X&amp;rdquo; on every day a GitHub outage had cost him working time. &lt;em&gt;Almost every day had an X.&lt;/em&gt; Reading that, I realised I&amp;rsquo;d been having the same month. I&amp;rsquo;d just never been disciplined enough to write it down. He&amp;rsquo;d turned my vague &amp;ldquo;it&amp;rsquo;s been flaky lately&amp;rdquo; into a row of crosses on a calendar.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;I want to ship software and it doesn&amp;rsquo;t want me to ship software.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;When the person who&amp;rsquo;s been on the platform for eighteen years and &lt;em&gt;loves&lt;/em&gt; it says that out loud, it stops being your private grumble. It&amp;rsquo;s the canary, and the canary has stopped singing.&lt;/p&gt;
&lt;h2 id="why-gitlab-and-not-just-somewhere-else"&gt;Why GitLab, and not just &amp;ldquo;somewhere else&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;Being annoyed at GitHub is a reason to leave. It is not, on its own, a reason to pick a destination. The destination has to be a positive choice.&lt;/p&gt;
&lt;p&gt;For me GitLab was an easy one, because I&amp;rsquo;ve been a fan for years. Long enough, in fact, to have &lt;em&gt;also&lt;/em&gt; been a reliable grumbler about their pricing tiers, which is how you know it&amp;rsquo;s a real relationship and not a honeymoon. What I&amp;rsquo;ve always rated is the model: GitLab treats source hosting, CI/CD, the package registry, releases and Pages as &lt;em&gt;one integrated product&lt;/em&gt;, not a marketplace of bolted-on parts you assemble yourself.&lt;/p&gt;
&lt;p&gt;That integration is the actual prize. On the old setup, &amp;ldquo;CI&amp;rdquo; meant a folder of separate GitHub Actions workflow files, each pinned, each its own little world. On GitLab it&amp;rsquo;s a single &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; pipeline with proper stages (lint, test, security, docs, release) and the release stage talks to the built-in package registry and Pages without me wiring up a single external credential. The CI job that builds the project can authenticate to the things the project needs &lt;em&gt;because they&amp;rsquo;re the same platform&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second-order benefit too. A migration is a rare licence to fix things you&amp;rsquo;d never otherwise touch. Moving gave me the cover to reset go-tool-base&amp;rsquo;s versioning cleanly (back to a sensible &lt;code&gt;v0.x&lt;/code&gt; line, the accidental &lt;code&gt;v2.0.0&lt;/code&gt; left behind as a cautionary tale) and to move the module path to its new home in one deliberate change rather than a thousand apologetic ones.&lt;/p&gt;
&lt;h2 id="what-im-not-going-to-claim"&gt;What I&amp;rsquo;m not going to claim
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;m not going to tell you GitHub is finished, or that GitLab never has a bad day, because it does, everyone does. This isn&amp;rsquo;t a teardown. GitHub gave go-tool-base a perfectly good home for its first year, and the archived mirror is still sitting there, read-only, pointing anyone who finds it at the new place.&lt;/p&gt;
&lt;p&gt;What changed is simpler than a grand verdict. The friction crossed a line, someone I respect said the quiet part loudly enough that I couldn&amp;rsquo;t keep filing it under &amp;ldquo;weather&amp;rdquo;, and the place I&amp;rsquo;d have moved to anyway was sitting right there with a better model. Sometimes the prudent move and the move you secretly wanted turn out to be the same move, and you just need a wrong version number to give you permission.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;go-tool-base moved from GitHub to GitLab in May 2026. The proximate cause was a self-inflicted version-bump mess; the real cause was months of GitHub unreliability that I&amp;rsquo;d stopped consciously noticing until Mitchell Hashimoto&amp;rsquo;s very public departure named it for me. GitLab was a positive pick, not just an escape hatch: its integrated CI/CD, registry, releases and Pages are one product rather than a kit, and that integration is genuinely worth having. The migration also bought a clean versioning restart as a bonus.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;ve been absorbing a steady drip of friction and telling yourself it&amp;rsquo;s normal: try the calendar trick. Mark the X&amp;rsquo;s for a month. The page will tell you something you already half-know.&lt;/p&gt;</description></item><item><title>Why I hand-rolled every module</title><link>https://blog-570662.gitlab.io/why-i-hand-rolled-every-module/</link><pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/why-i-hand-rolled-every-module/</guid><description>&lt;img src="https://blog-570662.gitlab.io/why-i-hand-rolled-every-module/cover-why-i-hand-rolled-every-module.png" alt="Featured image of post Why I hand-rolled every module" /&gt;&lt;p&gt;There are well-known community module libraries for AWS: Cloud Posse, the &lt;code&gt;terraform-aws-modules&lt;/code&gt; collection, plenty more. Both &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; use almost none of them. Every sub-module is hand-rolled from raw AWS resources, and before you accuse me of not-invented-here syndrome (a perfectly fair first guess), hear me out, because the same evaluation kept landing the same way for a real reason.&lt;/p&gt;
&lt;h2 id="the-promise-of-a-wrapper-module"&gt;The promise of a wrapper module
&lt;/h2&gt;&lt;p&gt;The community module ecosystem makes an appealing offer. Don&amp;rsquo;t write raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_policy&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_public_access_block&lt;/code&gt; and the rest. Call a tested, popular module, pass it a handful of inputs, and get a correct, well-configured bucket. Less code in your repo, and the code you don&amp;rsquo;t write has been exercised by thousands of other users.&lt;/p&gt;
&lt;p&gt;For a lot of infrastructure that&amp;rsquo;s a genuinely good deal, and I take it often. For the two infrastructure modules in this series, I took it almost never. Every sub-module is built from raw AWS resources. That wasn&amp;rsquo;t a reflex. It was the same evaluation, made over and over, landing the same way.&lt;/p&gt;
&lt;h2 id="what-kept-going-wrong"&gt;What kept going wrong
&lt;/h2&gt;&lt;p&gt;For each place a wrapper module could have fitted, I looked at the wrapper. And the recurring finding was one of two things. Either using the wrapper &lt;em&gt;correctly&lt;/em&gt;, with all the overrides my posture needed, came to more configuration than the raw resources would have. Or the wrapper&amp;rsquo;s abstraction leaked the instant I needed something it hadn&amp;rsquo;t anticipated, and I was now writing code to fight it.&lt;/p&gt;
&lt;h2 id="the-cloudtrail-bucket-concretely"&gt;The CloudTrail bucket, concretely
&lt;/h2&gt;&lt;p&gt;The clearest example is the bucket that holds CloudTrail logs.&lt;/p&gt;
&lt;p&gt;There are popular modules that set up CloudTrail and bundle an S3 bucket for the logs. Convenient. But that bundled bucket isn&amp;rsquo;t the bucket I want. It doesn&amp;rsquo;t carry &lt;code&gt;lifecycle { prevent_destroy = true }&lt;/code&gt;, and its bucket policy is weaker than the one &lt;a class="link" href="https://blog-570662.gitlab.io/a-state-bucket-that-defends-itself/" &gt;the state bucket&lt;/a&gt; taught me to want: TLS-only, SSE-KMS-only, wrong-key-denied.&lt;/p&gt;
&lt;p&gt;So to use the wrapper I had two options. Accept a weaker audit-log bucket than the rest of the account, which rather defeats the point of an audit log. Or fight the wrapper: disable its bucket, create my own, wire it back in. Fighting the wrapper is &lt;em&gt;more&lt;/em&gt; work than simply writing the fifty-odd lines of raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; plus policy that give me exactly the posture I&amp;rsquo;d already designed once. The wrapper didn&amp;rsquo;t save code. It added a negotiation.&lt;/p&gt;
&lt;h2 id="a-wrapper-is-a-deal-and-deals-have-terms"&gt;A wrapper is a deal, and deals have terms
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that community modules are bad. It&amp;rsquo;s an argument about when the deal is good.&lt;/p&gt;
&lt;p&gt;A wrapper module is a good deal while its abstraction holds: while what it assumes you want matches what you want. The moment you need something it didn&amp;rsquo;t anticipate, the deal inverts. Now you&amp;rsquo;re working &lt;em&gt;against&lt;/em&gt; the abstraction, and an abstraction you&amp;rsquo;re fighting costs more than no abstraction at all. (Regular readers will recognise that line from &lt;a class="link" href="https://blog-570662.gitlab.io/an-ai-interface-that-fits-on-one-screen/" &gt;the LangChain argument&lt;/a&gt;; it&amp;rsquo;s the same principle in a very different language.)&lt;/p&gt;
&lt;p&gt;Infrastructure that holds signing keys is precisely the case where you need to control the specifics: every encryption setting, every lifecycle rule, every line of every bucket policy. That&amp;rsquo;s a domain where wrapper abstractions leak fast, because the whole job &lt;em&gt;is&lt;/em&gt; the details the wrapper smoothed over.&lt;/p&gt;
&lt;h2 id="the-cost-paid-on-purpose"&gt;The cost, paid on purpose
&lt;/h2&gt;&lt;p&gt;Hand-rolling isn&amp;rsquo;t free. It&amp;rsquo;s more lines of HCL in the repo, up front, than a one-line module call.&lt;/p&gt;
&lt;p&gt;What those lines buy is worth the price &lt;em&gt;for this kind of infrastructure&lt;/em&gt;. There&amp;rsquo;s no transitive module-version churn to track. There&amp;rsquo;s no abstraction between me and the resource when something behaves oddly. And every line is one I can read, and defend, in a security review, because I wrote it and it says exactly what it does. For a foundation that will hold the most sensitive key in the system, &amp;ldquo;readable and mine&amp;rdquo; beats &amp;ldquo;short and someone else&amp;rsquo;s&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a deliberate trade, not a universal rule. For an internal tool on a deadline, reach for the wrapper. For the security-critical base of everything else, the raw resources won every time I checked.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;The community module ecosystem offers less code that more people have tested, and for plenty of infrastructure that&amp;rsquo;s the right call. For &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; it almost never was, because each wrapper turned out to be more configuration than the raw resources once my posture was accounted for, or it leaked the moment I needed a specific.&lt;/p&gt;
&lt;p&gt;The CloudTrail log bucket is the pattern in miniature: the bundled bucket lacked &lt;code&gt;prevent_destroy&lt;/code&gt; and a strong policy, so using the wrapper meant either a weaker bucket or fighting the module. A wrapper is a good deal while its abstraction holds and a bad one the moment you fight it, and security-critical foundation infrastructure is all specifics. Hand-rolling cost more lines and bought code I can read and defend. For this, that was the trade worth making.&lt;/p&gt;</description></item><item><title>Hardening the account that will hold the keys</title><link>https://blog-570662.gitlab.io/hardening-the-account-that-will-hold-the-keys/</link><pubDate>Sat, 09 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/hardening-the-account-that-will-hold-the-keys/</guid><description>&lt;img src="https://blog-570662.gitlab.io/hardening-the-account-that-will-hold-the-keys/cover-hardening-the-account-that-will-hold-the-keys.png" alt="Featured image of post Hardening the account that will hold the keys" /&gt;&lt;p&gt;&lt;a class="link" href="https://blog-570662.gitlab.io/the-bootstrap-that-does-almost-nothing/" &gt;Bootstrapping the account&lt;/a&gt; got it &lt;em&gt;ready&lt;/em&gt;: somewhere to store state, an identity to deploy as, enough for the next &lt;code&gt;tofu apply&lt;/code&gt; to run. Ready is not the same as safe. An account with no audit trail, nothing watching it, and no considered way for a human to get in is fine for experimenting and absolutely not where you put the most sensitive key in the system. So before the signing key goes anywhere near it, the account gets a security baseline.&lt;/p&gt;
&lt;h2 id="ready-is-not-the-same-as-safe"&gt;Ready is not the same as safe
&lt;/h2&gt;&lt;p&gt;The bootstrap post ended with an account that was &lt;em&gt;ready&lt;/em&gt;: it had somewhere to store state and a CI identity to deploy as. The next &lt;code&gt;tofu apply&lt;/code&gt; could run.&lt;/p&gt;
&lt;p&gt;Ready is not safe. That account still has no audit trail, so nobody could tell you afterwards what happened in it. It has no threat detection, so nothing is watching. Its defaults are AWS&amp;rsquo;s defaults, which are not a security posture. There&amp;rsquo;s no considered way for a human to get in. An account in that condition is fine for experimenting. It&amp;rsquo;s not somewhere you put the most sensitive key in the whole system.&lt;/p&gt;
&lt;p&gt;So before the signing key is anywhere near it, the account gets a security baseline.&lt;/p&gt;
&lt;h2 id="the-baseline-in-one-downstream-stack"&gt;The baseline, in one downstream stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; is that baseline, and it&amp;rsquo;s exactly the downstream stack the bootstrap post promised: applied &lt;em&gt;through&lt;/em&gt; the automation role bootstrap created, not bootstrapped specially.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s six sub-modules, each behind an &lt;code&gt;enable_*&lt;/code&gt; toggle: &lt;code&gt;account-hardening&lt;/code&gt; (IAM password policy, account-wide S3 public-access blocking, default EBS encryption), &lt;code&gt;audit-logging&lt;/code&gt; (a multi-region CloudTrail with log-file validation), &lt;code&gt;aws-config&lt;/code&gt;, &lt;code&gt;threat-detection&lt;/code&gt; (GuardDuty, Security Hub, IAM Access Analyzer), &lt;code&gt;alerts&lt;/code&gt;, and &lt;code&gt;operator-role&lt;/code&gt;. Together they turn a bare account into one that records what happens, watches for trouble, and controls who gets in.&lt;/p&gt;
&lt;p&gt;Most of those are the expected baseline. The operator role is the one worth slowing down on, because it&amp;rsquo;s built backwards from how people usually think about an admin role.&lt;/p&gt;
&lt;h2 id="the-operator-role-and-the-inversion"&gt;The operator role, and the inversion
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;InfraAdmin&lt;/code&gt; is the human way into the account: the role a person assumes to do operator work. Two things define it.&lt;/p&gt;
&lt;p&gt;The trust policy decides &lt;em&gt;who&lt;/em&gt; may assume it. It trusts only the account root principal, and it requires multi-factor authentication: the assume call must carry &lt;code&gt;aws:MultiFactorAuthPresent&lt;/code&gt;, and &lt;code&gt;aws:MultiFactorAuthAge&lt;/code&gt; bounds how recently that MFA was performed. No MFA, no role. So far this is a careful but ordinary admin role.&lt;/p&gt;
&lt;p&gt;The inversion is a &lt;em&gt;second&lt;/em&gt;, separate inline policy, and it&amp;rsquo;s almost entirely &lt;code&gt;Deny&lt;/code&gt;. It denies, using &lt;code&gt;NotAction&lt;/code&gt;, anything where &lt;code&gt;aws:RequestedRegion&lt;/code&gt; falls outside an allowed set of regions. The role&amp;rsquo;s &lt;em&gt;power&lt;/em&gt; comes from an admin grant. This inline policy &lt;em&gt;fences&lt;/em&gt; that power.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the part worth holding onto. People picture an admin role as a list of what it can do. This one is better understood by what it &lt;em&gt;cannot&lt;/em&gt;: it cannot act outside its permitted regions, full stop. A fat-fingered command, or a compromised session, cannot quietly spin resources up in some region nobody&amp;rsquo;s watching. The fence is as much the point of the role as the grant is.&lt;/p&gt;
&lt;h2 id="the-carve-out-because-honesty"&gt;The carve-out, because honesty
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a fiddly detail, and it&amp;rsquo;s the kind of thing that makes the region fence real rather than theoretical.&lt;/p&gt;
&lt;p&gt;Some AWS services are global. IAM, CloudFront, Route 53 and friends have no region, and they don&amp;rsquo;t honour &lt;code&gt;aws:RequestedRegion&lt;/code&gt;. A naive region-deny would therefore deny calls to IAM, and you&amp;rsquo;d lock yourself out of the very service you manage access with. (A close cousin of the kind of self-inflicted lockout I&amp;rsquo;ll come back to in a &lt;a class="link" href="https://blog-570662.gitlab.io/a-403-you-cant-fix-in-iam/" &gt;later post&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;So the Deny carries explicit carve-outs for the global services. It isn&amp;rsquo;t elegant, and it can&amp;rsquo;t be: the global-versus-regional split is just a fact of AWS, and a correct region fence has to account for it. The carve-out list is the honest cost of the control working.&lt;/p&gt;
&lt;h2 id="harden-the-room-then-move-the-keys-in"&gt;Harden the room, then move the keys in
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s an order to all of this, and the order is the argument.&lt;/p&gt;
&lt;p&gt;The account that will hold the signing key has to be audited before the key arrives, so that from day one every call against it is in CloudTrail. It has to be watched before the key arrives, so GuardDuty is already looking. It has to be access-controlled before the key arrives, so the only human path in is MFA-gated and region-fenced.&lt;/p&gt;
&lt;p&gt;You don&amp;rsquo;t move something valuable into a room and then think about locks. You build the room, fit the locks, check they work, and &lt;em&gt;then&lt;/em&gt; move the valuable thing in. The security baseline is fitting the locks. The signing key comes later, into a room already built for it.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;Bootstrapping an account makes it ready for the next deploy. It does not make it safe to hold anything that matters. &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; is the downstream stack that closes that gap: audit logging, AWS Config, threat detection, account hardening, and an operator role, applied through the CI role bootstrap created.&lt;/p&gt;
&lt;p&gt;The operator role is the piece to study. It&amp;rsquo;s MFA-gated on the way in, and then fenced by a separate, almost-all-&lt;code&gt;Deny&lt;/code&gt; inline policy that confines it to permitted regions, with carve-outs for the global services that have no region. An admin role defined as much by its fence as its grant. Harden the room first; the keys move in afterwards.&lt;/p&gt;</description></item><item><title>The chicken-and-egg of remote state</title><link>https://blog-570662.gitlab.io/the-chicken-and-egg-of-remote-state/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/the-chicken-and-egg-of-remote-state/</guid><description>&lt;img src="https://blog-570662.gitlab.io/the-chicken-and-egg-of-remote-state/cover-the-chicken-and-egg-of-remote-state.png" alt="Featured image of post The chicken-and-egg of remote state" /&gt;&lt;p&gt;Here&amp;rsquo;s a puzzle that every infrastructure-as-code setup hits exactly once, right at the very beginning, and then never again. An OpenTofu stack stores its state in a backend. &lt;a class="link" href="https://blog-570662.gitlab.io/the-bootstrap-that-does-almost-nothing/" &gt;The bootstrap stack&lt;/a&gt; I wrote about last time has a particular job, and part of that job is to &lt;em&gt;create&lt;/em&gt; the backend that remote state lives in. So where does the bootstrap stack store its own state, on the very first run, before it&amp;rsquo;s built the place state is supposed to go?&lt;/p&gt;
&lt;h2 id="where-does-the-state-of-the-thing-that-makes-the-state-store-live"&gt;Where does the state of the thing that makes the state store live?
&lt;/h2&gt;&lt;p&gt;That&amp;rsquo;s the puzzle, and it&amp;rsquo;s a real ordering deadlock rather than a riddle.&lt;/p&gt;
&lt;p&gt;An OpenTofu stack keeps a state file, and for anything shared that state file lives in a remote backend: on AWS, an S3 bucket. Fine. But the bootstrap stack has a particular job, and part of that job is to &lt;em&gt;create the S3 bucket that remote state lives in&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;So walk through the first run. Bootstrap has never been applied. The state bucket doesn&amp;rsquo;t exist, because creating it is what bootstrap is for. Bootstrap needs somewhere to store its own state. The only place that would make sense is the bucket it&amp;rsquo;s about to create, which isn&amp;rsquo;t there yet. The thing that builds the state store can&amp;rsquo;t store its state in the state store.&lt;/p&gt;
&lt;h2 id="run-local-then-migrate"&gt;Run local, then migrate
&lt;/h2&gt;&lt;p&gt;The way out is a two-step that OpenTofu supports directly.&lt;/p&gt;
&lt;p&gt;Bootstrap starts configured with a &lt;em&gt;local&lt;/em&gt; backend: &lt;code&gt;backend &amp;quot;local&amp;quot; {}&lt;/code&gt;. State is just a file on the operator&amp;rsquo;s machine. With that in place, the first &lt;code&gt;tofu apply&lt;/code&gt; runs. It creates the S3 bucket and the KMS key, and records all of it in the local state file.&lt;/p&gt;
&lt;p&gt;Now the bucket exists. So the backend configuration is rewritten to point at it: an &lt;code&gt;s3&lt;/code&gt; backend block naming the new bucket. Then &lt;code&gt;tofu init -migrate-state&lt;/code&gt;. OpenTofu sees the backend has changed, picks up the local state file, and copies it into the S3 bucket. From that point on, bootstrap&amp;rsquo;s own state lives in the bucket that bootstrap created. The egg has laid the chicken.&lt;/p&gt;
&lt;p&gt;The local backend was a scaffold. It existed for exactly one apply, to break the ordering deadlock, and then the state moved off it and it was never used again.&lt;/p&gt;
&lt;h2 id="it-happened-twice"&gt;It happened twice
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; repo actually did this migration twice, and the second time is the proof that the pattern is general rather than a one-off trick.&lt;/p&gt;
&lt;p&gt;The first migration was the one above: local to S3, at the very start. The second came later, during the &lt;a class="link" href="https://blog-570662.gitlab.io/why-we-left-github-for-gitlab/" &gt;move from GitHub to GitLab&lt;/a&gt;. GitLab offers a managed HTTP state backend, and &lt;code&gt;infra&lt;/code&gt; chose to use it. So the backend block was rewritten again, this time from &lt;code&gt;s3&lt;/code&gt; to &lt;code&gt;http&lt;/code&gt;, and &lt;code&gt;tofu init -migrate-state&lt;/code&gt; ran again, copying the state from the S3 bucket to GitLab&amp;rsquo;s backend.&lt;/p&gt;
&lt;p&gt;The same move, twice, against three different backends. That&amp;rsquo;s the useful lesson hiding in the chicken-and-egg story. State is portable. The backend is just &lt;em&gt;where you currently keep it&lt;/em&gt;, not a property of the stack itself, and moving it is a routine, supported operation rather than surgery.&lt;/p&gt;
&lt;h2 id="why-this-is-the-honest-answer-not-a-hack"&gt;Why this is the honest answer, not a hack
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to look at &amp;ldquo;apply once with a local backend, then migrate&amp;rdquo; and feel it&amp;rsquo;s a bit of a smell, a workaround for something that should have been cleaner.&lt;/p&gt;
&lt;p&gt;It isn&amp;rsquo;t. It&amp;rsquo;s the honest answer to a real ordering problem, and the alternatives are worse.&lt;/p&gt;
&lt;p&gt;The obvious alternative is to create the state bucket by hand, in the console, before running bootstrap at all. But then the most important bucket in the account is unmanaged. It exists outside every OpenTofu graph, nobody&amp;rsquo;s code describes it, its encryption and policy and &lt;code&gt;prevent_destroy&lt;/code&gt; are whatever someone clicked that day, and it drifts. The local-then-migrate dance avoids exactly that. The bucket is created &lt;em&gt;by bootstrap&lt;/em&gt;, described in code, and tracked in bootstrap&amp;rsquo;s own state from its very first apply. It&amp;rsquo;s managed from birth.&lt;/p&gt;
&lt;p&gt;The chicken-and-egg isn&amp;rsquo;t a flaw to be embarrassed about. It&amp;rsquo;s just the shape of the problem when a stack has to build its own foundations, and OpenTofu&amp;rsquo;s &lt;code&gt;-migrate-state&lt;/code&gt; is the supported tool for exactly that shape.&lt;/p&gt;
&lt;h2 id="pulling-it-together"&gt;Pulling it together
&lt;/h2&gt;&lt;p&gt;Every OpenTofu stack needs a backend to store state, and the bootstrap stack&amp;rsquo;s job is to &lt;em&gt;create&lt;/em&gt; the backend, so on its first run the bucket it needs doesn&amp;rsquo;t yet exist.&lt;/p&gt;
&lt;p&gt;The resolution is to run bootstrap once with a local backend, let that apply create the bucket and key, then rewrite the backend configuration and &lt;code&gt;tofu init -migrate-state&lt;/code&gt; the state into the bucket bootstrap just made. The &lt;code&gt;infra&lt;/code&gt; repo did it twice, local to S3 and later S3 to GitLab, which shows the real point: state is portable, and the backend is just where you keep it. Doing it this way, rather than hand-creating the bucket, is what keeps that critical bucket managed in code from its very first day.&lt;/p&gt;</description></item><item><title>A state bucket that defends itself</title><link>https://blog-570662.gitlab.io/a-state-bucket-that-defends-itself/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/a-state-bucket-that-defends-itself/</guid><description>&lt;img src="https://blog-570662.gitlab.io/a-state-bucket-that-defends-itself/cover-a-state-bucket-that-defends-itself.png" alt="Featured image of post A state bucket that defends itself" /&gt;&lt;p&gt;OpenTofu&amp;rsquo;s remote state file is, quietly, the most sensitive thing in an infrastructure repo. It&amp;rsquo;s a plain JSON document listing every resource you manage, every ID, and, depending on your providers, the odd secret in clear text. So the S3 bucket that holds it can&amp;rsquo;t just be a bucket. It has to actively defend itself, on three separate fronts.&lt;/p&gt;
&lt;h2 id="the-most-sensitive-file-in-the-repo"&gt;The most sensitive file in the repo
&lt;/h2&gt;&lt;p&gt;OpenTofu, like Terraform, keeps a state file: a JSON document recording every resource the stack manages, its real-world ID, and its attributes. It&amp;rsquo;s how the tool knows what already exists. It&amp;rsquo;s also, quietly, the most sensitive file in the whole repo. It can hold resource identifiers an attacker would value, and depending on the providers in play it can hold secret values in clear text.&lt;/p&gt;
&lt;p&gt;Three bad things can happen to it. It can be deleted, and now the tool has forgotten everything it manages. It can be read by someone who shouldn&amp;rsquo;t. It can be corrupted by two runs writing at once. The bucket that holds remote state has to defend against all three, and &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&amp;rsquo;s &lt;code&gt;state-backend&lt;/code&gt; module is built around doing exactly that.&lt;/p&gt;
&lt;h2 id="the-dynamodb-lock-table-is-gone"&gt;The DynamoDB lock table is gone
&lt;/h2&gt;&lt;p&gt;Start with the corruption problem, because the answer changed recently.&lt;/p&gt;
&lt;p&gt;The long-standing pattern for remote state on AWS was an S3 bucket &lt;em&gt;plus&lt;/em&gt; a DynamoDB table. S3 held the state; the DynamoDB table held a lock, so two &lt;code&gt;apply&lt;/code&gt; runs couldn&amp;rsquo;t write at once. Everyone who&amp;rsquo;s done Terraform on AWS has provisioned that table, probably more times than they&amp;rsquo;d care to count.&lt;/p&gt;
&lt;p&gt;OpenTofu 1.10 made it unnecessary. The S3 backend gained &lt;code&gt;use_lockfile&lt;/code&gt;, which does the locking with a small lock &lt;em&gt;object&lt;/em&gt; in the same bucket, using S3&amp;rsquo;s conditional-write support. No separate table. The state backend is now genuinely one bucket and one key, with the lock living beside the state. It&amp;rsquo;s one fewer resource to create, one fewer thing to pay for, and one fewer moving part to reason about. The module takes the new path, and the DynamoDB table simply isn&amp;rsquo;t there.&lt;/p&gt;
&lt;h2 id="a-bucket-you-cant-delete-by-accident"&gt;A bucket you can&amp;rsquo;t delete by accident
&lt;/h2&gt;&lt;p&gt;Deletion is guarded with &lt;code&gt;lifecycle { prevent_destroy = true }&lt;/code&gt; on the bucket. With that set, OpenTofu refuses to produce a plan that would destroy the bucket. A stray &lt;code&gt;tofu destroy&lt;/code&gt;, a refactor that drops the resource, an accidental rename: all of them fail loudly instead of quietly taking the state bucket with them.&lt;/p&gt;
&lt;p&gt;This is also why the &lt;code&gt;state-backend&lt;/code&gt; module is hand-rolled from raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; resources rather than wrapping a community module like &lt;code&gt;terraform-aws-modules/s3-bucket&lt;/code&gt;. &lt;code&gt;prevent_destroy&lt;/code&gt; has to sit on the actual resource, and a &lt;code&gt;lifecycle&lt;/code&gt; block isn&amp;rsquo;t something you can pass into a wrapper module as an input. Hand-rolling the bucket keeps &lt;code&gt;prevent_destroy&lt;/code&gt; somewhere you can put it and, just as importantly, somewhere the next reader can see it. (There&amp;rsquo;s a whole post coming on &lt;a class="link" href="https://blog-570662.gitlab.io/why-i-hand-rolled-every-module/" &gt;why I hand-rolled every module&lt;/a&gt;; this is one of the reasons in miniature.)&lt;/p&gt;
&lt;h2 id="reject-anything-encrypted-wrong"&gt;Reject anything encrypted wrong
&lt;/h2&gt;&lt;p&gt;Confidentiality is the subtle one, because the obvious control isn&amp;rsquo;t enough.&lt;/p&gt;
&lt;p&gt;The bucket has a default encryption configuration: server-side encryption with the customer-managed KMS key. But default encryption is a &lt;em&gt;default&lt;/em&gt;. A client making a &lt;code&gt;PutObject&lt;/code&gt; call can override it per request, asking for plain &lt;code&gt;AES256&lt;/code&gt; or a different KMS key, and S3 will honour the override.&lt;/p&gt;
&lt;p&gt;So the module doesn&amp;rsquo;t rely on the default. The bucket policy explicitly denies the upload it doesn&amp;rsquo;t want. It denies any request not over TLS. It denies any &lt;code&gt;PutObject&lt;/code&gt; that isn&amp;rsquo;t using SSE-KMS. And it denies any &lt;code&gt;PutObject&lt;/code&gt; that names the &lt;em&gt;wrong&lt;/em&gt; KMS key. The default encryption config says &amp;ldquo;this is what you get if you don&amp;rsquo;t ask&amp;rdquo;; the bucket policy says &amp;ldquo;and you&amp;rsquo;re not allowed to ask for anything else&amp;rdquo;. State can only ever land encrypted, in transit and at rest, under the one key the module controls.&lt;/p&gt;
&lt;p&gt;One small companion setting: &lt;code&gt;bucket_key_enabled&lt;/code&gt;. With per-object SSE-KMS, every object operation is also a KMS API call, which costs money and can throttle. An S3 Bucket Key collapses those into far fewer KMS calls, cutting per-object KMS traffic by well over ninety per cent. It&amp;rsquo;s a one-line setting the module turns on and most people forget exists.&lt;/p&gt;
&lt;h2 id="in-short"&gt;In short
&lt;/h2&gt;&lt;p&gt;Remote state is the most sensitive file an infrastructure repo has, and the bucket that holds it has to defend against deletion, disclosure and corruption.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&amp;rsquo;s state backend handles corruption with OpenTofu 1.10&amp;rsquo;s &lt;code&gt;use_lockfile&lt;/code&gt;, dropping the old DynamoDB lock table entirely. It guards deletion with &lt;code&gt;prevent_destroy&lt;/code&gt;, which is also why the bucket is hand-rolled rather than wrapped. And it guards confidentiality with a bucket policy that denies non-TLS traffic and denies any upload not encrypted with the right KMS key, because default encryption is only a default and a client can override it. The state bucket isn&amp;rsquo;t just a place to put state. It&amp;rsquo;s built to refuse every wrong thing that could happen to it.&lt;/p&gt;</description></item><item><title>The bootstrap that does almost nothing</title><link>https://blog-570662.gitlab.io/the-bootstrap-that-does-almost-nothing/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/the-bootstrap-that-does-almost-nothing/</guid><description>&lt;img src="https://blog-570662.gitlab.io/the-bootstrap-that-does-almost-nothing/cover-the-bootstrap-that-does-almost-nothing.png" alt="Featured image of post The bootstrap that does almost nothing" /&gt;&lt;p&gt;A brand-new AWS account is a slightly nerve-wracking thing. It can do almost anything, it&amp;rsquo;s hardened against almost nothing, and the list of stuff you ought to set up before you trust it with anything real is long. The natural instinct is to write one big &amp;ldquo;set up the account&amp;rdquo; module that does the whole list in a single apply. I want to talk you out of that, because the bootstrap module I&amp;rsquo;m happiest with does almost nothing, on purpose.&lt;/p&gt;
&lt;h2 id="the-first-apply-problem"&gt;The first-apply problem
&lt;/h2&gt;&lt;p&gt;A brand-new AWS account is not ready for anything serious. Before you&amp;rsquo;d responsibly run real infrastructure into it, you want an account baseline: a password policy, account-wide S3 public-access blocking, default EBS encryption, CloudTrail, AWS Config, GuardDuty, alerting, a sensible human operator role. It&amp;rsquo;s a long list, and all of it matters.&lt;/p&gt;
&lt;p&gt;The instinct, faced with that list, is to write one big &amp;ldquo;set up the account&amp;rdquo; module and have it do everything. One &lt;code&gt;tofu apply&lt;/code&gt;, a fully prepared account, done.&lt;/p&gt;
&lt;p&gt;That instinct is worth resisting, and &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; resists it deliberately.&lt;/p&gt;
&lt;h2 id="three-things-and-a-hard-line"&gt;Three things, and a hard line
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; does three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;state-backend&lt;/code&gt;&lt;/strong&gt;, an S3 bucket and a customer-managed KMS key to hold remote Terraform state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;automation-iam&lt;/code&gt;&lt;/strong&gt;, an OIDC identity provider and an IAM role that CI assumes to apply everything else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;nuke-config&lt;/code&gt;&lt;/strong&gt;, which renders an &lt;a class="link" href="https://github.com/ekristen/aws-nuke" target="_blank" rel="noopener"
 &gt;aws-nuke&lt;/a&gt; configuration scoped to the account, for tearing a throwaway account back down.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s the whole module. Account hardening, CloudTrail, AWS Config, GuardDuty, the operator role, the alerting: none of it is in here. And it&amp;rsquo;s not absent by accident. The README has a section headed &amp;ldquo;what&amp;rsquo;s deliberately NOT in scope&amp;rdquo; that lists those exclusions out loud. The boundary is written down, because the boundary is the design.&lt;/p&gt;
&lt;h2 id="why-the-line-is-exactly-there"&gt;Why the line is exactly there
&lt;/h2&gt;&lt;p&gt;The reason the line sits where it does is the most useful idea in the module.&lt;/p&gt;
&lt;p&gt;Everything bootstrap excludes belongs in a &lt;em&gt;separate&lt;/em&gt; stack, applied &lt;em&gt;through the automation role bootstrap creates&lt;/em&gt;. Bootstrap&amp;rsquo;s only job is to get the account to the point where the next &lt;code&gt;tofu apply&lt;/code&gt; can run properly: somewhere to store state, and an identity to run as. Once those two things exist, hardening the account isn&amp;rsquo;t a special bootstrapping act. It&amp;rsquo;s just another apply, done the normal way: in CI, reviewed, versioned, deployed through the role.&lt;/p&gt;
&lt;p&gt;So the account baseline doesn&amp;rsquo;t need to be bundled into the bootstrap. It needs to be &lt;em&gt;downstream&lt;/em&gt; of it. Bootstrap builds the on-ramp; it doesn&amp;rsquo;t also have to be the motorway.&lt;/p&gt;
&lt;h2 id="a-narrow-module-stays-re-runnable"&gt;A narrow module stays re-runnable
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a practical payoff to the narrowness, and it&amp;rsquo;s about fear.&lt;/p&gt;
&lt;p&gt;Bootstrap is the one stack that &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; be applied through CI, because it&amp;rsquo;s what creates the CI identity in the first place. It runs locally, by a human, rarely. That&amp;rsquo;s exactly the kind of operation you want to be small, boring, and safe to repeat.&lt;/p&gt;
&lt;p&gt;A bootstrap module that also did account hardening would be a large, stateful thing managing dozens of resources. Re-running it would be a held-breath operation. Keeping it to three concerns keeps it the opposite: a small stack you can read top to bottom, re-run without anxiety, and reason about completely. The narrowness isn&amp;rsquo;t minimalism for its own sake. It&amp;rsquo;s what keeps the one human-applied stack trustworthy.&lt;/p&gt;
&lt;h2 id="the-boundary-is-the-feature"&gt;The boundary is the feature
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s tempting to judge a module by how much it does. A bootstrap module is the case where that&amp;rsquo;s exactly backwards. Its value is in how cleanly it stops.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; does the bare minimum to make an account ready for the next apply, writes down everything it refuses to do, and hands off to a downstream stack for all of it. The next post follows the trickiest of its three jobs: the state backend has a &lt;a class="link" href="https://blog-570662.gitlab.io/the-chicken-and-egg-of-remote-state/" &gt;genuine chicken-and-egg problem&lt;/a&gt;, because it has to store Terraform state in a bucket Terraform hasn&amp;rsquo;t created yet.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;A fresh AWS account needs a long list of things before it&amp;rsquo;s safe, and the obvious move is one big module that does the lot. &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; deliberately does only three: a state backend, a CI identity, and an account-scrub config. Everything else is written down as out of scope.&lt;/p&gt;
&lt;p&gt;The boundary is the design. The excluded work belongs in a downstream stack applied through the CI role bootstrap creates, so hardening is just a normal reviewed apply rather than a bootstrapping special case. And keeping the one human-run, locally-applied stack small is what keeps it safe to re-run. A bootstrap module is judged by where it stops.&lt;/p&gt;</description></item><item><title>A signing key needs somewhere to live</title><link>https://blog-570662.gitlab.io/a-signing-key-needs-somewhere-to-live/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/a-signing-key-needs-somewhere-to-live/</guid><description>&lt;img src="https://blog-570662.gitlab.io/a-signing-key-needs-somewhere-to-live/cover-a-signing-key-needs-somewhere-to-live.png" alt="Featured image of post A signing key needs somewhere to live" /&gt;&lt;p&gt;I left a door open a couple of posts ago, and it&amp;rsquo;s been quietly bothering me ever since. When I wrote about &lt;a class="link" href="https://blog-570662.gitlab.io/verifying-your-own-downloads/" &gt;verifying your own downloads&lt;/a&gt;, I was honest that a checksum sitting next to the binary only catches accidents. Anyone who can compromise the release platform can swap the binary and the checksum together, and the tool will happily verify one fake against the other.&lt;/p&gt;
&lt;p&gt;Closing that gap needs a signature. And a signature, it turns out, needs a surprising amount of infrastructure standing behind it. This is the first post about building that.&lt;/p&gt;
&lt;h2 id="the-door-the-last-post-left-open"&gt;The door the last post left open
&lt;/h2&gt;&lt;p&gt;A while back I wrote about verifying your own downloads: go-tool-base&amp;rsquo;s self-update command now checks the SHA-256 of every binary it downloads against the release&amp;rsquo;s published &lt;code&gt;checksums.txt&lt;/code&gt; before installing it.&lt;/p&gt;
&lt;p&gt;That post was honest about its own ceiling. A checksum file hosted &lt;em&gt;next to&lt;/em&gt; the binary it describes shares a trust root with that binary. Both come from the same release, on the same platform. Corruption, truncation, a CDN serving a stale object: a same-origin checksum catches all of those, because they&amp;rsquo;re accidents and the checksum wasn&amp;rsquo;t part of the accident. What it can&amp;rsquo;t catch is an attacker who&amp;rsquo;s compromised the release platform itself. Someone who can replace the binary can replace &lt;code&gt;checksums.txt&lt;/code&gt; in the same breath, and the tool will cheerfully verify the malicious download against the malicious checksum and call it good.&lt;/p&gt;
&lt;p&gt;The post named the fix and then deferred it: a signature whose trust root sits somewhere the release platform can&amp;rsquo;t reach. &amp;ldquo;That&amp;rsquo;s the next phase of this work.&amp;rdquo; This series is that phase.&lt;/p&gt;
&lt;h2 id="what-a-signature-actually-needs"&gt;What a signature actually needs
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why a signature helps where a checksum doesn&amp;rsquo;t, because it&amp;rsquo;s easy to wave the word &amp;ldquo;signature&amp;rdquo; around and assume it settles everything.&lt;/p&gt;
&lt;p&gt;A signature closes the gap only under two conditions. The verifying key, the public half, must reach the user by a path the release platform doesn&amp;rsquo;t control. And the signing key, the private half, must live somewhere the release platform can&amp;rsquo;t reach.&lt;/p&gt;
&lt;p&gt;The second condition is the one people skip. If the signing key sits in the same CI system that builds the release, you&amp;rsquo;ve gained almost nothing. An attacker who owns the CI owns the key, and a key they own will sign whatever they hand it. The signature verifies perfectly and means precisely nothing. A signature is only worth the distance between the signing key and the thing being signed. Put them in the same place and the distance is zero.&lt;/p&gt;
&lt;p&gt;So the signing key has to live in a different security domain from the release pipeline. Not a different folder. A different account, with a different blast radius, that the release platform has no standing access to.&lt;/p&gt;
&lt;h2 id="just-sign-the-binary-is-not-a-small-feature"&gt;&amp;ldquo;Just sign the binary&amp;rdquo; is not a small feature
&lt;/h2&gt;&lt;p&gt;That reframes a line item that sounds tiny. &amp;ldquo;Sign the release binary&amp;rdquo; unpacks into a list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;there must be a private signing key;&lt;/li&gt;
&lt;li&gt;it must live outside the release platform, in its own security domain;&lt;/li&gt;
&lt;li&gt;it must be access-controlled, audited, and protected from exfiltration;&lt;/li&gt;
&lt;li&gt;only the release pipeline may ask it to sign, and only by proving a short-lived, federated identity, never by holding a copy of the key.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s not a feature you bolt onto a CLI. That&amp;rsquo;s infrastructure.&lt;/p&gt;
&lt;p&gt;The shape of it: a cloud account, with the key held in a managed key service so the private key material never exists as a file on a disk that anyone, me included, can copy. The release pipeline authenticates to that account as itself, briefly, and asks the key service to produce a signature. The key never moves.&lt;/p&gt;
&lt;p&gt;But an account you&amp;rsquo;re going to trust with a signing key is itself something you have to get right first. An account with a weak baseline, no audit trail, and long-lived credentials lying around is not a safe home for the most security-sensitive key in the whole system. Before the key can move in, the house has to be built and the locks have to actually work.&lt;/p&gt;
&lt;h2 id="what-this-series-builds"&gt;What this series builds
&lt;/h2&gt;&lt;p&gt;So this turned into a rather longer project than &amp;ldquo;add a signature&amp;rdquo;, and the series follows it in order.&lt;/p&gt;
&lt;p&gt;It starts with bootstrapping a fresh AWS account: the deliberately minimal first &lt;code&gt;tofu apply&lt;/code&gt;, and the remote state backend that has a genuine chicken-and-egg problem. Then the credential question, which is the heart of it: how a CI pipeline deploys to AWS with no stored access key at all. Then hardening the account, so it&amp;rsquo;s genuinely safe to hold something valuable. Then the discipline of deploying changes to it: plans reviewed before they&amp;rsquo;re applied. Then the shared tooling that makes all of it repeatable.&lt;/p&gt;
&lt;p&gt;Every one of those pieces exists for the same reason. The signing key needs somewhere to live, and somewhere safe is not a default you&amp;rsquo;re handed. It&amp;rsquo;s a thing you build, deliberately, before you have anything worth protecting in it.&lt;/p&gt;
&lt;p&gt;The series ends where the verifying-downloads post pointed: a signing service whose key the release platform can&amp;rsquo;t touch, so a self-updating tool can finally verify that the binary it&amp;rsquo;s about to become is genuinely the one I published.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s self-update verifies downloads against a checksum, and a same-origin checksum stops accidents but not a compromise of the release platform. The fix is a signature, and a signature is only worth the distance between its signing key and the release pipeline.&lt;/p&gt;
&lt;p&gt;Holding that key safely means a private key that never leaves a managed key service, in a separate cloud account, reached only by a short-lived federated identity. That&amp;rsquo;s infrastructure, and a safe account is something you build before you trust it with anything. The rest of this series builds it, piece by piece, right up to the signing service itself.&lt;/p&gt;</description></item><item><title>Pre-populating Neo4J using Kubernetes Init Containers and neo4j-admin import</title><link>https://blog-570662.gitlab.io/pre-populating-neo4j-using-kubernetes-init-containers-and-neo4j-admin-import/</link><pubDate>Wed, 15 Jul 2020 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/pre-populating-neo4j-using-kubernetes-init-containers-and-neo4j-admin-import/</guid><description>&lt;img src="https://blog-570662.gitlab.io/pre-populating-neo4j-using-kubernetes-init-containers-and-neo4j-admin-import/maxresdefault.jpg" alt="Featured image of post Pre-populating Neo4J using Kubernetes Init Containers and neo4j-admin import" /&gt;&lt;p&gt;Recently there has been an uptake in the use of Neo4j by the Data Scientists. This is a good thing! they are wanting to use the right tool for the job. However we need to run it inside our k8s cluster as a portable readable data source that has been dynamically populated from a pile of data in a combination of PostgreSQL and MongoDB.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t a problem for them working locally, they install and spin up a local copy of Neo4j and can interact with it quite happily. They even realised that they can generate CSV&amp;rsquo;s from PostgreSQL and MongoDB and then import them, blindingly fast, into Neo4j using the &lt;code&gt;neo4j-admin&lt;/code&gt; tool that comes bundled. Fantastic!&lt;/p&gt;
&lt;p&gt;At least until they come to want to run their Neo instance inside our k8s cluster. That&amp;rsquo;s where I step in and turn them aside from creating their own custom neo4j image with a bespoke entry point that loads all the data for them in some crazy threaded bash scripting!&lt;/p&gt;
&lt;p&gt;&amp;ldquo;No, No, No!&amp;rdquo; I tell them. &amp;ldquo;It&amp;rsquo;s far easier to just add an init container to your pod, that will preload the data before Neo starts up&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Init containers, if you haven&amp;rsquo;t come across before, them are a special type of container that lives inside a k8s pod and are set to run &lt;strong&gt;BEFORE&lt;/strong&gt; your main container runs. In this case it means we can easily sequence a bash script to run the &lt;code&gt;neo4j-admin import&lt;/code&gt; before Neo4j is even started. And here is how we did it!&lt;/p&gt;
&lt;h2 id="the-script"&gt;The script
&lt;/h2&gt;&lt;p&gt;The data scientists had been using Neo4j 3.5.x locally because they had a need for the graph algorithms plugin (&lt;a class="link" href="https://github.com/neo4j-contrib/neo4j-graph-algorithms" target="_blank" rel="noopener"
 &gt;https://github.com/neo4j-contrib/neo4j-graph-algorithms&lt;/a&gt;) which at the time they were looking didn&amp;rsquo;t support Neo4j 4.x. The plugin is now deprecated and its replacement (&lt;a class="link" href="https://github.com/neo4j/graph-data-science" target="_blank" rel="noopener"
 &gt;https://github.com/neo4j/graph-data-science&lt;/a&gt;) thankfully supports 3.5.x and 4.x.&lt;/p&gt;
&lt;p&gt;As Neo4j 4.x introduces a lot of new features and improves performance so I recommended we switch to using that. This meant a refactor of their bash script for neo4j-admin there some very subtle differences and a few caveats to work with. This is what they came up with&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/bash
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;DBNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;neo4j&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$#&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -eq &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;DBNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# extract data from SQL&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 extract_data.py
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# remove old db for rebuild&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf &lt;span class="s2"&gt;&amp;#34;/data/databases/&lt;/span&gt;&lt;span class="nv"&gt;$DNBAME&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;neo4j-admin import &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --database&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$DBNAME&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --delimiter&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;|&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --nodes&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;Protein&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NODE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_protein_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_proteins.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --nodes&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;UniProtKB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NODE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_uniprot_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_uniprot.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --relationships&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;HAS_AMINO_ACID_SEQUENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EDGE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_protein_sequence_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_protein_sequence.csv &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --relationships&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;HAS_AMINO_ACID_SEQUENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EDGE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_chembl_protein_biotherapeutic_molregno_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_chembl_protein_biotherapeutic_molregno.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --skip-bad-relationships&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --skip-duplicate-nodes&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;import&lt;/code&gt; command here is significantly shorter for example purposes, as the original is about 120 lines long. As you can see it&amp;rsquo;s pretty straight forward, they had another script in &lt;code&gt;extract_data.py&lt;/code&gt;, that I wont bore you with suffice to say that it pulled out all the data they wanted from PostgreSQL and MongoDB, which got saved to disk as CSV files in the relevant directories.&lt;/p&gt;
&lt;p&gt;Great, it worked on their local version!&lt;/p&gt;
&lt;h2 id="the-dockerfile"&gt;The Dockerfile
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ROM neo4j:latest
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4JLABS_PLUGINS [&amp;#34;graph-data-science&amp;#34;]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RUN apt update &amp;amp;&amp;amp; apt install -y python3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;WORKDIR /srv
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;COPY src /srv/src
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;COPY headers /srv/headers
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The plan is always to keep it simple. We have one image that we can run for both the init container and the main container. This docker file gives a vanilla neo4j instance with python and our scripts for extracting the data loaded into it&lt;/p&gt;
&lt;h2 id="the-k8s-manifest"&gt;The k8s Manifest
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;v1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Pod&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;NEO4J_AUTH&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j/password&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;registry.example.com/phpboyscout/rnd_graph:latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Always&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumeMounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;mountPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;subPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;initContainers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;importer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;neo4j_import.sh&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/bin/bash&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;DATA_DIR&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/import/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;HEADER_DIR&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/srv/headers&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;registry.example.com/phpboyscout/rnd_graph:latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Always&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;workingDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/srv/src&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumeMounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;mountPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;subPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;mountPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/import&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;subPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;import&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;claimName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now we can pull it all together with our k8s manifest. From here you can see that we have our default neo4j container that we pass in our default authentication details to and an init container that runs our &lt;code&gt;import.sh&lt;/code&gt; script. Both containers have access to a shared volume for the &lt;code&gt;/import&lt;/code&gt; and &lt;code&gt;/data&lt;/code&gt; folders.&lt;/p&gt;
&lt;p&gt;And now we get to&amp;hellip;&lt;/p&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting
&lt;/h2&gt;&lt;p&gt;So right off the bat it didn&amp;rsquo;t work! No surprises there but here are a few things that caused us some issues and how we resolved them.&lt;/p&gt;
&lt;h3 id="database-offline"&gt;Database offline
&lt;/h3&gt;&lt;p&gt;At first glance everything seemed to work. Until we tried to connect to the &lt;code&gt;neo4j&lt;/code&gt; database with the default UI, at which point we were presented with the error message&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Database &amp;#34;neo4j&amp;#34; is unavailable, its status is &amp;#34;offline.&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This took a little sleuthing and shelling into the neo4j container to take a look at the &lt;code&gt;/var/debug.log&lt;/code&gt; file which gives significantly more useful information about whats going on with the server. First we were getting stack traces that contained messages like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Component &amp;#39;org.neo4j.kernel.impl.transaction.log.files.TransactionLogFiles@59d6a4d1&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;was successfully initialized, but failed to start. Please see the attached cause 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;exception &amp;#34;/data/transactions/neo4j/neostore.transaction.db.0&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;From experience this sounded like a permissions issue and lo and behold, checking the files on the filesystem showed that because the import script was run as root the database files were owned by root. We resolved this by adding:-&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R neo4j:neo4j /data/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;to the bottom of the import script. Next we were then presented with an error that looked like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2020-07-14 16:56:33.919+0000 WARN [o.n.k.d.Database] [neo4j] Exception occurred while
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;starting the database. Trying to stop already started components. Mismatching store id.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This one seems like it would be an obvious one to google and I did come up with few pages that seemed to describe what was happening to me but gave some varied solutions, from starting and stopping the sever and running &lt;code&gt;neo4j-admin unbind&lt;/code&gt; in between to deleting various files. It seemed very strange because we did test this with the 3.5.17 version of Neo and it worked fine.&lt;/p&gt;
&lt;p&gt;The solution we needed was to wipe the slate clean properly. The line in our script to remove the previous build of the db&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# remove old db for rebuild&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf &lt;span class="s2"&gt;&amp;#34;/data/databases/&lt;/span&gt;&lt;span class="nv"&gt;$DNBAME&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;just didn&amp;rsquo;t cut it. It turns out that because the 4.x version of Neo4j supports multiple databases the &lt;code&gt;import&lt;/code&gt; command writes additional information to the system database and transactions database in the form of some identifiers for each database, BUT if you don&amp;rsquo;t do something to clear that value for the database your are building it wont match up when the server starts and you get a declaration of &lt;code&gt;Mismatching store id&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m not sure if the developers are aware of this flaw, so in the mean time we have to expand our cleanup to:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# clean up for fresh import&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf /data/databases/*
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf /data/transactions/*
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;removing the neoj4, system and store_lock databases and transaction logs from the data store. This solved the problem and the server was able to start and we could connect to neo4j database successful.&lt;/p&gt;
&lt;p&gt;Its not an ideal solution, I can foresee definite situations we will have to work around when we get to a point where multiple databases may be needed and are built separately and independently from each other. but it will suffice for now.&lt;/p&gt;
&lt;h3 id="malloc-error-message-goes-here"&gt;Malloc(): Error message goes here
&lt;/h3&gt;&lt;p&gt;Once it was up and running we noticed that we were getting lots of restarts on the main neo4j container a quick look at the &lt;code&gt;stdout&lt;/code&gt; log and we could see each restart ending with something that looked like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;malloc(): corrupted top size
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;instantly this looks like an issue with memory sizing inside the container for the JVM. Thankfully the team at Neo4j have accounted for this and give you a nice tool in the form of&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;neo4j-admin memrec
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;which interrogates the databases and gives some sensible values you can set in the output which in our case looked like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Memory settings recommendation from neo4j-admin memrec:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Assuming the system is dedicated to running Neo4j and has 376.6GiB of memory,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# we recommend a heap size of around 31g, and a page cache of around 331500m,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# and that about 22400m is left for the operating system, and the native memory&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# needed by Lucene and Netty.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: If the indexing storage use is high, e.g. there are many indexes or most&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# data indexed, then it might advantageous to leave more memory for the&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# operating system.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: Depending on the workload type you may want to increase the amount&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# of off-heap memory available for storing transaction state.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# For instance, in case of large write-intensive transactions&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# increasing it can lower GC overhead and thus improve performance.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# On the other hand, if vast majority of transactions are small or read-only&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# then you can decrease it and increase page cache instead.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: The more concurrent transactions your workload has and the more updates&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# they do, the more heap memory you will need. However, don&amp;#39;t allocate more&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# than 31g of heap, since this will disable pointer compression, also known as&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# &amp;#34;compressed oops&amp;#34;, in the JVM and make less effective use of the heap.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: Setting the initial and the max heap size to the same value means the&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# JVM will never need to change the heap size. Changing the heap size otherwise&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# involves a full GC, which is desirable to avoid.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Based on the above, the following memory settings are recommended:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;dbms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initial_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;dbms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;dbms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pagecache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;331500&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# It is also recommended turning out-of-memory errors into full crashes,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# instead of allowing a partially crashed database to continue running:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#dbms.jvm.additional=-XX:+ExitOnOutOfMemoryError&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# The numbers below have been derived based on your current databases located at: &amp;#39;/var/lib/neo4j/data/databases&amp;#39;.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# They can be used as an input into more detailed memory analysis.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Total size of lucene indexes in all databases: 0k&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Total size of data and native indexes in all databases: 17300m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So how to get these values into the container&amp;hellip; Thankfully this is handled for you in the form of Environment Variables you can pass into the docker image. A bit of a google and i found this little snippet which is a goldmine for telling us how to translate settings into environment variables.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Env variable naming convention:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - prefix NEO4J_&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - double underscore char &amp;#39;__&amp;#39; instead of single underscore &amp;#39;_&amp;#39; char in the setting name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - underscore char &amp;#39;_&amp;#39; instead of dot &amp;#39;.&amp;#39; char in the setting name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Example:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# NEO4J_dbms_tx__log_rotation_retention__policy env variable to set&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# dbms.tx_log.rotation.retention_policy setting&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As for getting the variables into the container, you could do this from the pod and inject it in. I this case because the data we are going to be using is reasonably stable and tested we decided to stick them into the Docker file with the &lt;code&gt;ENV&lt;/code&gt; directive.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4J_dbms_memory_heap_initial__size 31g
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4J_dbms_memory_heap_max__size 31g
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4J_dbms_memory_pagecache_size 331500m
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And so far we haven&amp;rsquo;t had a restart yet!&lt;/p&gt;</description></item></channel></rss>