DEV Community

Mr-T02
Mr-T02

Posted on

The 2-Hour Bash Bug That Taught Me How Quoting Actually Works

I've been learning DevOps fundamentals, and to actually cement it, I built mini-paas — a tiny, from-scratch clone of the "git push to deploy" flow that Heroku and Render sell as a service. Push code to a bare Git repo, and a post-receive hook automatically checks it out, builds a Docker image, and swaps the old container for the new one — no manual steps.

Along the way, I hit a bug that took me about two hours to track down. It wasn't a crash. It wasn't a stack trace. It was worse — it just silently did the wrong thing, over and over, while looking like it was working. Here's how I found it.

The problem

Once the basic pipeline was working — push, checkout, build, run — I added logic to make redeploys clean: stop the old container, remove it, then start a fresh one. The whole point was to make repeated pushes safe, so the app could be updated indefinitely without manual cleanup.

Instead, every second push started failing:

docker: Error response from daemon: Conflict. The container name "/myapp" is already in use...
Enter fullscreen mode Exit fullscreen mode

The exact thing I'd written the stop/remove logic to prevent was happening anyway.

First attempts

My first instinct was that I must have messed up the script itself, so I went through the hook line by line, checking for syntax errors — misplaced quotes, wrong flags, that kind of thing. Nothing jumped out. The syntax looked completely fine.

Then I tried something that made it more confusing, not less: I ran the exact same commands manually —

docker stop myapp
docker rm myapp
Enter fullscreen mode Exit fullscreen mode

— and they worked perfectly. Instantly. No errors, no delay. So Docker wasn't broken, WSL wasn't broken, the container definitely existed. But the moment Git triggered the same script automatically, those same commands failed to do anything.

That mismatch — works by hand, fails when automated — is really disorienting to debug, because your instinct is to keep re-checking the thing that's obviously fine (the commands) instead of questioning the thing you assumed was fine (how they're being run).

Diagnosis: reaching for set -x

Eventually I stopped guessing and added set -x to the top of the hook. This makes Bash print every command it runs, with variables already substituted — so instead of trusting what I wrote, I could see exactly what actually executed.

The very next push made it obvious:

+ docker stop APP_NAME
+ docker rm APP_NAME
+ docker run -d --name myapp -p 5001:5000 myapp
Enter fullscreen mode Exit fullscreen mode

Look closely at the difference. docker run correctly used myapp — the real value. But docker stop and docker rm were running against the literal text APP_NAME — not the variable's value, just the word itself.

There was no container named APP_NAME, so of course stop/rm did nothing. They weren't broken — they were succeeding, just against a target that never existed.

The fix

The cause was inconsistent quoting. Somewhere in editing the script, $APP_NAME had ended up without proper double-quote expansion on those two lines, while the rest of the script used it correctly. In Bash, that's the difference between "substitute this variable's value" and "treat this as literal text."

The fix was small:

# before
docker stop APP_NAME
docker rm APP_NAME

# after
docker stop "$APP_NAME"
docker rm "$APP_NAME"
Enter fullscreen mode Exit fullscreen mode

One character's worth of difference — $ plus consistent quoting — and the entire redeploy cycle started working correctly: old container found, stopped, removed, new one started cleanly, no conflicts.

The lesson

The whole thing took about two hours, and looking back, most of that time wasn't spent finding the fix — it was spent doubting the wrong layer. I assumed the problem had to be Docker, or WSL, or something environmental, because the commands looked right and worked fine by hand. It took actually tracing execution, not just reading the script, to see the truth.

That's the real takeaway: bugs that fail silently — no error, no crash, just quietly wrong behavior — are nearly impossible to catch by reading code alone. Reading code shows you what you intended. Tracing execution shows you what actually happened. Those aren't always the same thing, and this bug only existed in that gap.

Next time I hit something that "should be working" but isn't, I'm reaching for set -x immediately — not as a last resort after I've already exhausted manual testing, but as the first real diagnostic step.

A couple of smaller stumbles, for good measure

Two other beginner-ish gotchas along the way, worth a mention:

  • I pushed to master while following a workflow that assumed main as the default branch — a quick reminder to always check what your actual default branch is called before assuming.
  • I once committed straight to GitHub with unresolved <<<<<<< HEAD / ======= / >>>>>>> merge conflict markers still sitting in my README, in plain sight, for anyone to see. Cleaning that up was a good reminder to always double-check a file after any merge, not just assume it resolved cleanly.

Neither took nearly as long to fix as the quoting bug — but both were good reminders that the small, "obvious" stuff is exactly where beginners (myself included) tend to trip.


If you want to see the full project — the Git hook, the Python CLI, the Nginx config, and the architecture behind all of it — it's up on GitHub: github.com/Mr-T02/mini-paas

Top comments (0)