<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Abhinav</title>
    <description>The latest articles on Forem by Abhinav (@abhinav-balki).</description>
    <link>https://forem.com/abhinav-balki</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3862765%2F9b88a2c3-532f-4dea-8332-150e8b147410.jpg</url>
      <title>Forem: Abhinav</title>
      <link>https://forem.com/abhinav-balki</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/abhinav-balki"/>
    <language>en</language>
    <item>
      <title>Prompting Without the Menu</title>
      <dc:creator>Abhinav</dc:creator>
      <pubDate>Mon, 27 Apr 2026 06:56:21 +0000</pubDate>
      <link>https://forem.com/abhinav-balki/prompting-without-the-menu-297i</link>
      <guid>https://forem.com/abhinav-balki/prompting-without-the-menu-297i</guid>
      <description>&lt;p&gt;I read a list-format post on prompting techniques over the weekend. Few-shot, chain-of-thought, ReAct, RAG, self-consistency, meta-prompting... fifteen items, flat list, equal weight. Each one explained briefly, each one given the same visual real estate.&lt;/p&gt;

&lt;p&gt;The format is the problem.&lt;/p&gt;

&lt;p&gt;When you list techniques as parallel options, you train the reader to pick the one that &lt;em&gt;sounds&lt;/em&gt; most appropriate to their task. That's how prompting becomes cargo-culting. Someone reads "use chain-of-thought for complex reasoning" and starts adding &lt;em&gt;Let's think step by step&lt;/em&gt; to every prompt that feels hard. The technique gets used, but it's not solving anything specific, because nothing specific had been diagnosed in the first place.&lt;/p&gt;

&lt;p&gt;Prompting techniques aren't options. They're responses to failure modes. The list-format post collapses that distinction, and the collapse is what makes prompting feel like trial-and-error instead of engineering.&lt;/p&gt;

&lt;p&gt;Here's how I've started thinking about it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  A workflow is a pipeline, not a single prompt
&lt;/h2&gt;

&lt;p&gt;Most of the techniques in those lists assume you're optimizing one prompt: one input, one output, get the wording right. That framing is wrong for anything beyond a one-shot question. Real work with an LLM is a pipeline, and the stages aren't symmetric.&lt;/p&gt;

&lt;p&gt;Early stages reduce entropy. Later stages spend tokens on reasoning. If you skip the first kind and jump straight to the second, every downstream technique compounds noise instead of reducing it.&lt;/p&gt;

&lt;p&gt;Compression has to come first. Before generating anything, three questions do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What exists? (the actual current state: files, schemas, code paths, existing logic)&lt;/li&gt;
&lt;li&gt;What's broken? (the specific gap, not the vague feeling of wrongness)&lt;/li&gt;
&lt;li&gt;What should the output look like? (shape, not content)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip this and the model fills the gap with probabilistic noise. You'll get plausible-looking output that's wrong in ways that take longer to debug than the original problem. Every prompting technique downstream of a vague problem statement is rearranging deck chairs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Techniques sort by what they're for, not how they sound
&lt;/h2&gt;

&lt;p&gt;Once you've compressed, the techniques fall into four groups based on what kind of failure they address:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern biasing (low-cost control knobs).&lt;/strong&gt; Few-shot, role, format, instruction prompting. These are persistent constraints, not reactive fixes. Set them once at the top of the workflow (&lt;em&gt;prefer minimum diff, no abstraction until justified, assume repo context&lt;/em&gt;) and inject them only when the model drifts. The mistake is treating them as prompts to write fresh each time. They're more like config than instructions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasoning scaffolds (conditional, expensive).&lt;/strong&gt; Chain-of-thought, tree-of-thought, reflection. Reflection is the cheapest and most local, so it should be the default. Generate, then immediately ask what failure modes and edge cases exist, then patch. Only escalate to CoT or ToT when the model is visibly guessing at structure rather than missing facts. These trade cost and latency for accuracy... that trade is worth it less often than the lists suggest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External state interaction (for missing data).&lt;/strong&gt; Prompt chaining, ReAct, least-to-most. These are about getting information &lt;em&gt;into&lt;/em&gt; the workflow that wasn't there before. Use when the task decomposes cleanly into stages, or when the model needs to act on the world before continuing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Systems we can borrow from (RAG-shaped).&lt;/strong&gt; A repo, a logs directory, a diff history are all RAG layers in disguise. The lesson from RAG isn't &lt;em&gt;use a vector database&lt;/em&gt;. It's &lt;em&gt;don't describe; point&lt;/em&gt;. Specific files, specific versions, specific diffs. Description introduces drift; pointing grounds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe that changed the most
&lt;/h2&gt;

&lt;p&gt;The biggest single shift in how I prompt: stop asking the model whether the output is correct.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Is this right?&lt;/em&gt; invites defense. The model has just produced the output. Asking it to grade itself produces a confident affirmative most of the time, because the same machinery that generated the output is now being asked to evaluate it.&lt;/p&gt;

&lt;p&gt;The better question: &lt;strong&gt;what assumptions did you make, and what breaks first?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This exposes seams instead of inviting justification. It forces the model to surface the implicit decisions baked into the output: the input formats it assumed, the edge cases it didn't handle, the constraints it inferred but didn't verify. Once those are visible, you can decide whether each one is acceptable. &lt;em&gt;Is this correct&lt;/em&gt; gets you a yes. &lt;em&gt;What breaks first&lt;/em&gt; gets you a list.&lt;/p&gt;

&lt;p&gt;This is also a better reflection prompt than the standard "review your answer for errors" pattern, because it doesn't depend on the model finding its own mistakes. It depends on the model articulating its own assumptions, which is a much more reliable thing to ask of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling state, not history
&lt;/h2&gt;

&lt;p&gt;For anything multi-turn, the default failure mode is context bloat. Every turn appends to the history, the history gets too long, you compact it, and compaction loses the load-bearing context: the early decisions, the constraints, the invariants that everything downstream depends on.&lt;/p&gt;

&lt;p&gt;The fix is to maintain a compressed state explicitly. Not history. State. Key decisions, active constraints, things-that-must-not-change. Update it as you go. When context gets tight, you compact the conversational history but never the state.&lt;/p&gt;

&lt;p&gt;Dense context. Saved tokens. Preserved direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to skip
&lt;/h2&gt;

&lt;p&gt;Some techniques in the typical list increase entropy without giving you a way to reduce it back. Zero-shot prompting (when you have any examples available), self-consistency for tasks that aren't ambiguous, meta-prompting for problems you haven't compressed yet, these add cost and uncertainty without a corresponding reduction in either. They have their place, but they're not the default tools, and listing them with equal weight to reflection or grounding is misleading.&lt;/p&gt;

&lt;p&gt;Worse: they're often the techniques that &lt;em&gt;feel&lt;/em&gt; sophisticated, which means people reach for them first. Self-consistency feels rigorous. Meta-prompting feels meta. Both are easy ways to spend tokens without spending thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual diagnostic
&lt;/h2&gt;

&lt;p&gt;Here's the decision logic, condensed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output is wrong in a vague way?&lt;/strong&gt; Reduce entropy. You haven't compressed the problem yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output has wrong structure or format?&lt;/strong&gt; Pattern bias: few-shot, format, instruction. Cheap and high-leverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning slipped somewhere?&lt;/strong&gt; Add scaffolding. Reflection first; CoT or ToT only when the model is guessing at structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output is factually off?&lt;/strong&gt; Ground it. Point at files, versions, diffs. Don't describe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure mode → fix type. That's the whole framework. The specific techniques are implementations of these four moves.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmf53kvafeats37qbpba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmf53kvafeats37qbpba.png" alt="Prompt Diagnostic" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is also why list-format posts feel unsatisfying after a while. They give you the techniques without the diagnostic, which is like handing someone a toolbox without telling them how to identify what's broken. You end up applying tools by feel rather than by indication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is going
&lt;/h2&gt;

&lt;p&gt;The reason I've been thinking about this isn't really about prompting techniques. It's about the layer above them... how developers, platforms, and users work with generative AI systems, and where the friction in that interaction comes from.&lt;/p&gt;

&lt;p&gt;The friction isn't usually that the model is bad. It's that the interface between human intent and model output is underspecified at every level. List-format posts on prompting techniques are a symptom of that, they're trying to make the interface tractable by enumerating its surface, but the actual problem is structural.&lt;/p&gt;

&lt;p&gt;That's a longer thread, and not for this post. But it's where the next few are going.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post grew out of reading one too many "complete guide to prompting" lists. The decision tree above is my attempt to compress what those lists actually need, into something diagnostic instead of encyclopedic.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Dockerized a Production AI System as an Intern. Here's What Actually Mattered.</title>
      <dc:creator>Abhinav</dc:creator>
      <pubDate>Mon, 06 Apr 2026 04:49:03 +0000</pubDate>
      <link>https://forem.com/abhinav-balki/i-dockerized-a-production-ai-system-as-an-intern-heres-what-actually-mattered-2bmd</link>
      <guid>https://forem.com/abhinav-balki/i-dockerized-a-production-ai-system-as-an-intern-heres-what-actually-mattered-2bmd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;No CI/CD. No Kubernetes. Just PuTTY, WinSCP, and a system that needed to stop being fragile.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The System I Walked Into
&lt;/h2&gt;

&lt;p&gt;I'm an intern on an AI team. My project is an internal AI support tool: an augmented RAG-based system that ingests knowledge bases, searches resolved tickets via vector similarity, and synthesizes resolutions using an LLM. FastAPI backend, React frontend, PostgreSQL with pgvector, ChromaDB for embeddings, OpenAI for generation.&lt;/p&gt;

&lt;p&gt;The AI pipeline is interesting. The infrastructure it was running on was not.&lt;/p&gt;

&lt;p&gt;Here's what "deployment" looked like when I joined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Directory A (test):
  └── git pull → run uvicorn directly on EC2

Directory B (production):
  └── teammate manually copies changed files from Directory A
  └── find-and-replace URLs
  └── run uvicorn directly on EC2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four ports exposed + separate frontend and backend for both environments. No containerization. No build step for the frontend. No rollback mechanism. No isolation between test and production beyond "they're in different folders." The frontend was not even served via Vite's dev server in production.&lt;/p&gt;

&lt;p&gt;If the EC2 instance had a bad day, reconstruction was from memory and hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqtj4192i26w3gweit981.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqtj4192i26w3gweit981.png" alt="Docker-Architecture"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One directory. Docker Compose with overlay files for environment separation. nginx as a reverse proxy (two ports instead of four). Image versioning with semantic tags and timestamp backups. A deploy script. A rollback script. Full isolation between prod and test + different Docker networks, different data volumes, different container names.&lt;/p&gt;

&lt;p&gt;Deploy went from "copy files and pray" to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./deploy.sh prod 1.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Constraints That Shaped Everything
&lt;/h2&gt;

&lt;p&gt;This is the part I actually want to talk about. The Docker setup isn't novel... anyone can follow a tutorial. What made this interesting was what I couldn't do.&lt;/p&gt;

&lt;h2&gt;
  
  
  No CI/CD
&lt;/h2&gt;

&lt;p&gt;No GitHub Actions. No webhooks. No automated pipelines. My deployment tools are PuTTY (SSH terminal) and WinSCP (file transfer). That's it. So I built a shell script that acts as a poor-man's pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./deploy.sh [stack] [version] [branch]
     │
     ├── git pull origin [branch]
     ├── tag current running images as backup
     ├── docker compose build
     ├── docker compose up -d
     └── prune dangling images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The branch argument means I can test feature branches on the test stack without merging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./deploy.sh &lt;span class="nb"&gt;test &lt;/span&gt;latest feature/new-rag-pipeline
&lt;span class="c"&gt;# verify on internal IP&lt;/span&gt;
&lt;span class="c"&gt;# merge PR&lt;/span&gt;
./deploy.sh prod 1.3 main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is this as good as GitHub Actions with automated tests and staging environments? No. Does it work reliably for a single-server deployment with one developer? Yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared Secrets, Different Environments
&lt;/h2&gt;

&lt;p&gt;The backend reads its config from a single .env file: database credentials, API keys, OIDC settings. Both prod and test use the same file because they're on the same machine talking to the same database.&lt;/p&gt;

&lt;p&gt;But the OIDC redirect URIs must differ between environments. Prod redirects to the public DNS. Test redirects to the internal IP.&lt;/p&gt;

&lt;p&gt;The solution: Docker Compose's precedence rules. environment: in a compose file beats env_file:. So the base compose file loads the shared secrets via env_file:, and each overlay (prod.yml, test.yml) overrides just the OIDC URI via environment:.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml (base)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend/.env&lt;/span&gt;  &lt;span class="c1"&gt;# shared secrets&lt;/span&gt;

&lt;span class="c1"&gt;# docker-compose.prod.yml (overlay)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OIDC_REDIRECT_URI_FRONTEND=https://public.dns.com&lt;/span&gt;

&lt;span class="c1"&gt;# docker-compose.test.yml (overlay)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OIDC_REDIRECT_URI_FRONTEND=http://&amp;lt;IP&amp;gt;:&amp;lt;Port&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small detail. It's also the kind of thing that causes a two-hour debugging session if you don't know about it. env_file values get silently overridden by environment values, and there's no warning, no log, nothing. You just get the wrong redirect and stare at your OIDC provider's error page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Volume Isolation Without Duplication
&lt;/h2&gt;

&lt;p&gt;ChromaDB stores embeddings on disk. The knowledge base files live on disk. Logs go to disk. Prod and test need completely separate copies of all of these. You don't want a test run corrupting production embeddings.&lt;/p&gt;

&lt;p&gt;Docker Compose variable substitution handles this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./backend/${CHROMA_DIR:-chromaDB}:/app/chromaDB&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./backend/${DATA_DIR:-data}:/app/data&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./backend/${LOGS_DIR:-logs}:/app/logs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.test
&lt;/span&gt;&lt;span class="py"&gt;CHROMA_DIR&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;chromaDB-test&lt;/span&gt;
&lt;span class="py"&gt;DATA_DIR&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;data-test&lt;/span&gt;
&lt;span class="py"&gt;LOGS_DIR&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;logs-test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default values point to prod directories. When you pass --env-file .env.test, the paths switch to test directories. Same compose file, different data. The deploy script handles this automatically, and you never pass --env-file manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Override File Pattern
&lt;/h2&gt;

&lt;p&gt;Docker Compose has a feature where docker-compose.override.yml is automatically loaded alongside docker-compose.yml, but only when you don't use explicit -f flags.&lt;/p&gt;

&lt;p&gt;I used this to create three distinct modes from the same codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Local development (override.yml auto-loaded)&lt;/span&gt;
docker compose watch
→ Vite dev server with HMR, OIDC pointing to localhost

&lt;span class="c"&gt;# Production (explicit -f, override.yml skipped)&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml up
→ nginx serves built static files, OIDC pointing to public DNS

&lt;span class="c"&gt;# Test (explicit -f, override.yml skipped)  &lt;/span&gt;
docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env.test &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.test.yml up
→ nginx serves built static files, OIDC pointing to internal IP, &lt;span class="nb"&gt;test &lt;/span&gt;volumes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One base file. Three overlays. Three completely different behaviors. The developer never thinks about which files to compose: docker compose watch just works locally, and ./deploy.sh picks the right overlay on EC2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration
&lt;/h2&gt;

&lt;p&gt;The scariest part was the cutover. Two directories, both running live. I needed to consolidate into one without downtime on prod.&lt;br&gt;
The sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stop old test stack (directory A)&lt;/li&gt;
&lt;li&gt;Stop old prod stack (directory B)
&lt;/li&gt;
&lt;li&gt;Copy prod data (ChromaDB, knowledge base, secrets) into directory A&lt;/li&gt;
&lt;li&gt;Create isolated test directories (start empty)&lt;/li&gt;
&lt;li&gt;Pull latest code with all new compose files&lt;/li&gt;
&lt;li&gt;Deploy prod from directory A → verify public DNS works&lt;/li&gt;
&lt;li&gt;Deploy test from directory A → verify internal IP works&lt;/li&gt;
&lt;li&gt;Archive directory B&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 6 is where you sweat. The public DNS now needs to resolve to the new container, with the right OIDC config, serving the right data. If anything is wrong, users see a broken page.&lt;/p&gt;

&lt;p&gt;It worked on the first try. Which means I probably over-prepared, but I'd rather over-prepare than explain to the team why production is down.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;

&lt;p&gt;The deploy script tags current images before rebuilding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resolvyst-backend:latest  →  resolvyst-backend:v1.2
                          →  resolvyst-backend:20260403_1430
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rollback reuses the saved image without touching data volumes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./rollback.sh prod v1.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is basic, but it's infinitely better than what existed before (nothing). The timestamp tag is insurance. Even if you forget to bump the version, you can still roll back to any previous deploy by timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently With More Access
&lt;/h2&gt;

&lt;p&gt;If I had CI/CD and wasn't constrained to PuTTY:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Health checks in the compose file. Right now, deploy.sh reports success even if the backend crashes on startup. A curl check post-deploy would catch that.&lt;/li&gt;
&lt;li&gt;Separate secrets per environment. The shared .env file works but is fragile; one wrong edit affects both stacks.&lt;/li&gt;
&lt;li&gt;Automated smoke tests after deploy. Hit the health endpoint, verify the RAG pipeline returns a response, check that OIDC redirects correctly.&lt;/li&gt;
&lt;li&gt;Git working tree check at the top of deploy.sh. Right now, nothing stops you from deploying with uncommitted changes on EC2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But these are improvements to a system that already works. The first version doesn't need to be perfect. It needs to be better than what it replaced, and "someone manually copy-pasting files" is a low bar to clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Takeaway
&lt;/h2&gt;

&lt;p&gt;The interesting skill in infrastructure work isn't knowing Docker or nginx or Compose. It's designing around constraints you can't remove. I couldn't set up CI/CD. I couldn't get a second server. I couldn't change the OIDC provider's configuration beyond adding redirect URIs. I had an intern's access level.&lt;/p&gt;

&lt;p&gt;So I built something that works within those constraints. It's not elegant by industry standards. But it's reproducible, it's rollback-safe, it has environment isolation, and it replaced a process that depended on one person's memory of which files to copy where.&lt;/p&gt;

&lt;p&gt;That's the gap between knowing tools and doing systems design. Tools are things you learn. Systems design is figuring out what to do when the tools you want aren't available.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;I'm an intern working on AI systems: RAG pipelines, support ticket analytics, UX upgrades and apparently now DevOps. If you're working on similar problems or just want to talk about building things under real-world constraints, I'd love to connect.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
