Introduction: The Illusion of Total Control#
In the first part of this technical diary, I outlined the architecture of an immutable workstation based on DevPod. The goal was ambitious: a “Golden Image” containing every tool necessary for orchestrating my Kubernetes cluster (Proxmox, Talos, Longhorn), eliminating the entropy of local configuration. However, as every engineer knows, the transition from theory to practice exposes flaws that no planning can completely foresee.
In this session, I set an even more extreme goal: transforming the DevPod into a Zero Trust environment. I didn’t just want a container with my tools; I wanted a secure enclave where critical secrets (Kubeconfig, SSH keys, API tokens) would never reside on disk in plain text, even within the isolated container.
The mindset of the day was driven by constructive paranoia. I asked myself: “If someone physically compromised my laptop or managed to execute an unauthorized command in the container, what would they find?”. The answer had to be: “Absolutely nothing.”
This is the technical chronicle of how I tried to bend DevPod to this radical security vision, clashing with its own architecture oriented towards convenience, until reaching the inevitable decision to abandon the tool and start over on different foundations.
Phase 1: Image Refactoring and the Cache Nightmare#
Before addressing security, I had to solve a problem of architectural efficiency. My original Dockerfile was becoming an unmanageable monolith. Every small change to the dotfiles required a complete rebuild of the entire image, a process that consumed bandwidth and precious time.
The Reasoning: Layered Architecture#
I decided to decompose the image into three distinct logical layers:
- Base Layer (
Dockerfile.base): The foundation of the operating system, security tools (Infisical, SOPS), and stable binaries (Eza, Neovim, Starship). - Kubernetes Layer (
Dockerfile.k8s): The specific stack for orchestration (Kubectl, Helm, Talosctl). - AI Layer (
Dockerfile.gemini): The heavy Gemini CLI, which requires a dedicated Node.js runtime.
Conceptual Deep-Dive: Docker Layer Caching
Layer caching in Docker works according to a deterministic logic: if the content of an instruction (such as a RUN or COPY command) does not change, Docker reuses the previously built layer. This is fundamental for continuous integration (CI/CD). However, if a layer at the base of the chain changes, all subsequent layers are invalidated and must be rebuilt. By separating stable tools from heavy or frequently updated ones, I sought to maximize iteration speed.
The Symptom: The “Invisible” Cache#
During testing, I stumbled upon a frustrating behavior. I had updated the Starship theme in the dotfiles (switching from Gruvbox to a more restful Pastel Powerline), but despite running the build, the container continued to present itself with the old theme.
Checking the build logs, I noticed the infamous => CACHED label right on the COPY dotfiles/ command. Docker did not detect that the files inside the host folder had changed.
The Solution: Dynamic Cache Busting#
To force Docker to invalidate the cache at the exact desired point, I introduced a dynamic build argument.
# Dockerfile.base snippet
# ... stable tools ...
# Argument to force dotfiles update
ARG CACHEBUST=1
RUN echo "Cache bust: ${CACHEBUST}"
# Now Docker is forced to re-execute the copy if CACHEBUST changes
COPY --chown=vscode:vscode dotfiles/ /home/vscode/By launching the build with --build-arg CACHEBUST=$(date +%s), I injected the current timestamp into the process. Since the RUN echo command changed every second, Docker was mathematically obliged to rebuild that layer and all subsequent ones, guaranteeing the injection of the new configuration files.
Phase 2: The RAM Enclave and the Kernel Conflict#
Having solved the cache problem, I moved to the heart of the project: the Encrypted Vault. The idea was to create a LUKS (Linux Unified Key Setup) volume inside the container.
The Reasoning: Why LUKS in a Container?#
Normally, containers rely on kernel namespace isolation. But files inside a container are accessible to anyone with root privileges on the host or who can execute a docker exec. By encrypting a portion of the filesystem with LUKS and unlocking it only via a manually entered passphrase, secrets are protected by a cryptographic key that resides only in RAM (and in the user’s mind).
Conceptual Deep-Dive: Linux Unified Key Setup (LUKS) LUKS is the standard for disk encryption in Linux. It works by creating a layer between the physical device (or an image file) and the filesystem. This layer handles the on-the-fly decryption of data blocks. In the context of a container, using LUKS requires access to the host kernel’s Device Mapper, an operation that is inherently complex to isolate.
The Investigation: Loop Device Failure#
The first attempt to create the vault in RAM via tmpfs hit a kernel error: Attaching loopback device failed (loop device with autoclear flag is required).
In a Docker environment, even if the container is launched with the --privileged flag, the cryptsetup command often fails to automatically allocate loop devices (those virtual devices that allow a file to be treated as a hard disk). This happens because the nodes in /dev/loop* are not dynamically created inside the container.
The Solution: Mknod and Manual Losetup#
I had to implement a robust unlocking procedure that prepared the ground for the kernel:
# Snippet from the unlock script (devpod-zt.sh)
echo "🛠️ Preparing loop devices (0-63)..."
sudo mknod /dev/loop-control c 10 237 2>/dev/null || true
for i in $(seq 0 63); do
sudo mknod /dev/loop$i b 7 $i 2>/dev/null || true
done
echo "💾 Engaging Secure Enclave (RAM)..."
# Dedicated tmpfs mount to avoid /dev/shm limits
sudo mount -t tmpfs -o size=256M tmpfs "$VAULT_BASE"
# Manual loop device association
LOOP_DEV=$(sudo losetup -f --show "$VAULT_IMG")
echo -n "$PLAIN_PASS" | sudo cryptsetup luksFormat --batch-mode "$LOOP_DEV" -
echo -n "$PLAIN_PASS" | sudo cryptsetup open "$LOOP_DEV" "$MAPPER_NAME" -This move was crucial. By manually creating device nodes and managing the losetup association outside of cryptsetup’s automation, I succeeded in overcoming Docker runtime restrictions and finally mounting a working encrypted filesystem in ~/secrets.
Phase 3: The Clash Between Automation and Hardening#
With the vault working, I tried to automate the process. I wanted the container to ask for the password immediately upon entry. I implemented a Trap-Shell in the .bashrc: a script that intercepted the session start and launched the unlocking procedure.
The Symptom: “Ghosts” in the Logs#
As soon as the Trap-Shell was activated, I started seeing incessant output every 30 seconds in the devpod up logs:
00:32:47 debug Start refresh ... Device secrets_vault already exists.
The Analysis: The DevPod Agent Lifecycle#
Here I discovered the true nature of the DevPod Agent. To provide features like port forwarding and file sync, the DevPod agent maintains an open SSH channel or socket to the container. Every 30 seconds, the agent executes “refresh” commands (such as update-config) by launching new shells in the container.
Since my Trap-Shell was in the .bashrc, every time the agent entered for a routine check, the security script started, tried to ask for a password (which the agent couldn’t provide), or tried to remount an already active volume, generating cascading errors.
Conceptual Deep-Dive: Interactive vs Non-interactive Shells In Bash, shells can be interactive (connected to a terminal/TTY) or non-interactive (executed by a script or a daemon). The DevPod agent launches non-interactive shells. I tried to solve the problem by filtering the security script execution:
# Modification in .bashrc
if [[ $- == *i* ]]; then
# Run unlock only if user is at the screen
tazpod-unlock
fiAlthough this reduced the noise, it did not solve the underlying problem: DevPod Agent continued to “quarrel” with my hardened environment.
Phase 4: The Fall of SSH and the “Fail-Open” Discovery#
The final nail in the coffin of the DevPod-based approach was the attempt to harden SSH access. I wanted the vault to unmount automatically after exiting the shell and for reentry to require the password again.
I tried removing the SSH keys injected by DevPod (rm ~/.ssh/authorized_keys). The result? The DevPod agent panicked, losing the ability to manage the workspace. I tried implementing a background Watchdog that would count active bash processes and unmount the vault at the end of the last session. But the complexity was scaling exponentially compared to the benefits.
The “Ctrl+C” Vulnerability#
During a manual penetration test, I discovered an embarrassing flaw: if I pressed Ctrl+C during the Infisical password prompt, the script was interrupted but the shell gave me the command prompt anyway. It was a security system that could be bypassed with a single keystroke.
I responded by implementing a brutal SIGINT Trap:
# In .bashrc
trap "echo '❌ Interrupted. Exiting.'; exit 1; kill -9 $$" INTIt worked. But at that point, my development environment had become a web of hacks, fragile Bash scripts trying to manage kernel signals, and perennial conflicts with the DevPod orchestration agent.
Phase 5: Resignation and Paradigm Shift#
After hours spent fighting against the Device already exists error from the Device Mapper and the infinite refreshes of the agent, I reached a painful but necessary conclusion: DevPod is not the right tool for a Zero Trust enclave.
DevPod is built on the philosophy of Convenience-First. It wants you to be operational in one click, your SSH keys synced everywhere, your environment “always ready.” My security vision, however, requires an environment that is “never ready” until the user explicitly decides so.
The Decision: I decided to throw away all the work done with DevPod. I decided to eliminate the agent, the automatic SSH keys, and the integrated VS Code server.
The new approach will be based on:
- Pure Docker: A Debian Slim container launched manually with 100% controlled startup scripts.
- Go CLI: A dedicated CLI written in Go (which we will call
tazpod) to manage the entire security lifecycle in a robust and atomic way, eliminating the fragility of Bash scripts. - Terminal-Only Workflow: Abandoning VS Code in favor of Neovim (LazyVim), eliminating the need for persistent SSH channels for the IDE.
Conclusion: What We Learned in This Stage#
This session, seemingly a failure, was actually a masterclass in systems engineering. I learned that:
- Automation is not always an ally of extreme security.
- The host kernel and the container have a very tight dependency relationship when it comes to encryption, and intermediaries make debugging impossible.
- Knowing when to give up on a tool when it no longer meets requirements is a senior skill as fundamental as knowing how to configure it.
The Immutable Workshop is not dead; it is just shedding its skin. In the next post, I will document the birth of the TazPod CLI in Go and the transition to a Pure Docker environment, where control is no longer an option, but the very foundation of the architecture.


