Skip to main content

Command Palette

Search for a command to run...

Wrapping It in a Box, Properly

Day 3 of The Sentinel Logs

Updated
5 min read
Wrapping It in a Box, Properly
P
DevOps Engineer documenting the Journey

I promised a slightly embarrassing story at the end of yesterday's post, so let's start there instead of burying it.

The very first time I containerized SentinelAI, I didn't have a .dockerignore file. If you don't know what that is yet, think of it as a bouncer standing at the door of your container, deciding what's allowed in and what isn't. Without one, Docker's default behaviour is to copy everything in your project folder into the image — and "everything" includes things you really don't want shipped anywhere near production. My .env file, with local configuration values sitting in it. The entire .git folder, which carries your full commit history. Every markdown file. None of it belonged inside a running container, and all of it would have happily gone in if I'd built that image and pushed it somewhere.

Nothing leaked, because I caught it before it ever left my laptop. But it's exactly the kind of mistake that's invisible until it isn't — your image builds fine, runs fine, looks fine, and the only sign something's wrong is that it's quietly larger and more exposed than it needs to be. Once I added the .dockerignore, the image dropped weight immediately and stopped carrying anything it had no business carrying.

That was the easy fix. The more interesting decisions came after.

The base image. I went with python:3.12-slim rather than the full python:3.12 image or a bare alpine image. This is a genuinely common fork in the road, so here's how I thought about it: the full image comes with a lot of OS-level tooling you'll never use in production, which just means more surface area for vulnerabilities and a heavier image to pull every time you deploy. Alpine is the smallest option, but it uses a different C library under the hood (musl instead of glibc), which occasionally causes subtle compatibility issues with Python packages that expect glibc — not often, but often enough that I didn't want to debug it on a project where I was already juggling a dozen other moving parts. Slim sits in the middle: meaningfully smaller than the full image, without the compatibility landmines of Alpine.

Running as a non-root user. By default, anything inside a container runs as root unless you explicitly tell it not to. That's a problem because if an attacker ever manages to break out of the application layer through some vulnerability, root inside the container often makes it easier to escalate further. So the Dockerfile creates a dedicated sentinel user and group before anything else happens, and every file copied in afterward is explicitly handed over to that user instead of root. The container then switches to that user before it ever starts serving traffic. It's a small block of lines, but it's the difference between "if something goes wrong, the damage is contained" and "if something goes wrong, the attacker has the keys."

Keeping the layer order deliberate. Docker builds images in layers, and it caches each layer so it doesn't have to redo work that hasn't changed. I copy requirements.txt and install dependencies before copying the actual application code, on purpose. Application code changes constantly; dependencies change rarely. If I copied everything at once, every single code change would force Docker to reinstall every dependency from scratch on the next build. Ordering it this way means most rebuilds only have to redo the fast part — copying code — and skip the slow part entirely.

Now, the part I actually want to be honest about: I didn't make the backend a multi-stage build, and that was the right call, not a shortcut.

Multi-stage builds exist for a specific problem: when the tools you need to build something are heavier than the thing itself once it's built, and you don't want those build tools sitting in your final image. I'd practiced this pattern separately while preparing for interviews, and my first instinct was to apply it everywhere because it felt like the "more correct" thing to do. But when I actually looked at what the backend needed, almost every Python dependency in this project installs from a pre-built wheel — there's no heavy compiler toolchain being pulled in and then discarded. A builder stage would have added complexity without removing anything meaningful.

The frontend is a completely different story, and that's exactly where I did reach for multi-stage. Building the React app pulls in the entire Node toolchain and node_modules, which is enormous, and none of it is needed once the static files are actually built. So that Dockerfile builds the app in one stage using a Node image, then copies only the final compiled output into a clean Nginx image for the second stage — the build tools never make it into what actually ships. Same non-root pattern, same deliberate ownership on every copied file, plus a healthcheck so Kubernetes can tell on its own whether the server inside is actually answering requests.

That contrast is the real lesson from this part of the build: multi-stage isn't a best practice you sprinkle on every Dockerfile to look thorough. It's a tool for a specific shape of problem — when your build environment and your runtime environment genuinely need to be different things. Use it where that's true, and skip it where it isn't. Both Dockerfiles in this project get linted by Hadolint in CI, which I'll get into properly when I cover the security pipeline — but the linter catching a mistake is the safety net, not the strategy. The strategy is understanding what each container actually needs to carry before you write a single line.

Tomorrow, the backbone and the containers both get a place to actually run: Kubernetes, K3d, and the slightly unglamorous decision of building three separate environments before I had any real reason to need more than one.