<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Build. Break. Ship.]]></title><description><![CDATA[No polished tutorials. Just honest, step-by-step write-ups of projects from idea to deployment — bugs included.]]></description><link>https://shlokbam.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/66a2a621c1a21fb9d16d92fd/04642eae-b36d-4ee6-9b0e-db3f01f034d6.png</url><title>Build. Break. Ship.</title><link>https://shlokbam.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 07 Sep 2026 10:38:13 GMT</lastBuildDate><atom:link href="https://shlokbam.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I Built an AI-Powered Mock Interview Platform from Scratch — Here's Everything That Went Wrong]]></title><description><![CDATA[A full walkthrough of building MockVue — React + FastAPI + TiDB Cloud + Groq AI + face-api.js — including every bug, every architectural decision, and every "why is this not working" moment.

Before W]]></description><link>https://shlokbam.hashnode.dev/i-built-an-ai-powered-mock-interview-platform-from-scratch-here-s-everything-that-went-wrong</link><guid isPermaLink="true">https://shlokbam.hashnode.dev/i-built-an-ai-powered-mock-interview-platform-from-scratch-here-s-everything-that-went-wrong</guid><dc:creator><![CDATA[Shlok Bam]]></dc:creator><pubDate>Sun, 05 Apr 2026 19:59:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/31f0abde-2994-4c8c-b2d6-5e37bf68e846.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>A full walkthrough of building MockVue — React + FastAPI + TiDB Cloud + Groq AI + face-api.js — including every bug, every architectural decision, and every "why is this not working" moment.</em></p>
<hr />
<h2>Before We Start — Why I Built This</h2>
<p>I was preparing for campus placements. And I kept reading about companies like JPMorgan, Goldman Sachs, and TCS using AI-powered video assessment platforms for first-round interviews. You record yourself answering questions. An AI grades you. You never even speak to a human until the second round.</p>
<p>The problem? There was no good way to practice for this format. Mock interview tools either had fake questions, no video component, or gave you generic feedback like "speak more clearly." None of them actually simulated what these AI platforms do.</p>
<p>So I stopped looking for one and built it.</p>
<p>MockVue is a full-stack AI mock interview platform. You pick a company and role, answer 5 video questions under timed conditions, and get an AI-generated score across three dimensions: answer quality, speaking confidence, and eye contact. The feedback is detailed, the questions are company-specific, and the experience is close to what the actual platforms feel like.</p>
<p>This is the full story of building it — the architecture, every technical decision, every bug, and every "oh that's why" moment.</p>
<hr />
<h2>What I Built</h2>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/fed9c7bf-b41c-4098-aae5-d9c424b88d80.png" alt="" style="display:block;margin:0 auto" />

<p>A user picks a company (Google, JPMorgan, TCS, etc.) and a role. They get 5 questions. For each question: 30 seconds to read, 2 minutes to answer on camera. The platform records their video, tracks their eye contact using AI in real time, transcribes their audio on the server, and then sends everything to another AI model that grades the answer against a rubric.</p>
<p>Here's how the system fits together:</p>
<pre><code class="language-plaintext">User's Browser
     │
     ├── Camera + Mic (MediaRecorder API)
     ├── Real-time eye tracking (face-api.js)
     ├── Real-time speech analysis (Web Speech API)
     │
     ▼
React + Vite Frontend (Vercel)
     │
     │ POST /answers (multipart: audio + analytics)
     ▼
FastAPI Backend (Render)
     │
     ├── Whisper (Groq) ── transcribes audio
     ├── Llama 3.3 70B (Groq) ── grades answer vs rubric
     └── Stores result
          │
          ▼
     TiDB Cloud (Serverless MySQL)
</code></pre>
<p>Every time you submit an answer → audio goes to Groq Whisper → transcript goes to Groq Llama → scores come back → everything gets saved → you see a detailed feedback report.</p>
<p><strong>Tech Stack:</strong></p>
<table>
<thead>
<tr>
<th>What</th>
<th>Tool</th>
</tr>
</thead>
<tbody><tr>
<td>Frontend</td>
<td>React 19 + Vite</td>
</tr>
<tr>
<td>Backend</td>
<td>FastAPI (Python 3.12)</td>
</tr>
<tr>
<td>Database</td>
<td>TiDB Cloud Serverless</td>
</tr>
<tr>
<td>AI Evaluation</td>
<td>Groq (Llama 3.3 70B + Whisper)</td>
</tr>
<tr>
<td>Eye Tracking</td>
<td>face-api.js</td>
</tr>
<tr>
<td>Frontend Host</td>
<td>Vercel</td>
</tr>
<tr>
<td>Backend Host</td>
<td>Render</td>
</tr>
<tr>
<td>Auth</td>
<td>JWT (python-jose + bcrypt)</td>
</tr>
</tbody></table>
<hr />
<h2>Phase 1 — The Question Bank</h2>
<p>Before I wrote a single line of frontend code, I needed something to interview users with. A mock interview platform with generic questions is useless. I wanted company-specific, role-specific questions that felt like the real thing.</p>
<p>I curated 270+ behavioural and situational questions across 13 companies (Google, Amazon, Microsoft, Adobe, Meta, Netflix, Flipkart, JPMorgan, Goldman Sachs, TCS, Infosys, Swiggy, Zomato) and 5 roles per company (Software Engineer, Product Manager, Data Analyst, UX Designer, Operations).</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/6150a3fd-e2f8-43f6-b169-b9e741861d79.png" alt="" style="display:block;margin:0 auto" />

<p>Each question has a rubric. Here's an example:</p>
<pre><code class="language-python">{
    "company": "JPMorgan",
    "role": "Software Engineer",
    "question_text": "Describe a technical challenge you faced and how you solved it.",
    "rubric": [
        {"point": "Clearly described the technical problem", "points": 8},
        {"point": "Explained your thought process and approach", "points": 8},
        {"point": "Mentioned specific technologies or tools used", "points": 8},
        {"point": "Quantified the result or outcome", "points": 8},
        {"point": "Reflected on what you learned", "points": 8},
    ],
    "model_answer": "During my internship, our microservice was crashing under load..."
}
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> Instead of asking the AI "was this answer good?", I give it a checklist with point values. It scores each item on the checklist separately. This means feedback is specific — "you didn't mention the outcome" — instead of just "answer was mediocre."</p>
</blockquote>
<p>The rubric matters because it's what the AI uses for grading. Instead of just asking "was this answer good?", I send Groq the rubric and ask it to score each point specifically. This produces much more actionable feedback.</p>
<p>I also built a <code>seed_db.py</code> script so anyone can clone the repo and populate their database in one command:</p>
<pre><code class="language-bash">cd backend
python3 seed_db.py
</code></pre>
<p>One important design decision: I built a fallback. If someone picks a company/role combination that has no specific questions, the backend returns General HR questions instead of a 404 error. The app never fails silently.</p>
<hr />
<h2>Phase 2 — The Backend (FastAPI + TiDB Cloud)</h2>
<h3>Why FastAPI?</h3>
<p>FastAPI was the right choice for one specific reason: it handles async I/O natively, and I was going to be making multiple Groq API calls per answer submission. With a synchronous framework, each API call blocks the server. FastAPI's async handlers let me structure the code cleanly even on a budget hosting plan.</p>
<h3>The Database Setup</h3>
<p>I chose TiDB Cloud Serverless. It's MySQL-compatible, has a free tier, runs entirely in the cloud, and scales to zero — which matters on a student budget.</p>
<p>The tricky part was SSL configuration. TiDB Cloud requires SSL, and the CA certificate path is different on every operating system. I wrote a fallback chain to handle this automatically:</p>
<pre><code class="language-python">ca_paths = [
    "/etc/ssl/cert.pem",                     # Render / Alpine
    "/etc/ssl/certs/ca-certificates.crt",    # Ubuntu / Debian
    "/etc/pki/tls/certs/ca-bundle.crt"       # CentOS / RHEL
]
ca_path = next((p for p in ca_paths if os.path.exists(p)), ca_paths[0])
connect_args = {"ssl": {"ca": ca_path}}
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> SSL is like a security handshake between your app and the database. To do that handshake, your app needs a specific certificate file — but that file lives in different places on different servers. This code tries each possible location in order until it finds one that exists.</p>
</blockquote>
<p>This is one of those things that works perfectly on your local Mac and then fails on Render because Render uses a different Linux distribution. The fallback chain saved me from an hour of debugging SSL errors in production.</p>
<p>The database also had a driver issue. TiDB's connection string sometimes comes back from the dashboard as <code>mysql://</code> without the <code>+pymysql</code> specifier. SQLAlchemy doesn't know which MySQL driver to use — it defaults to MySQLdb, which I hadn't installed. One-line fix:</p>
<pre><code class="language-python">if "mysql://" in DATABASE_URL and "+pymysql" not in DATABASE_URL:
    DATABASE_URL = DATABASE_URL.replace("mysql://", "mysql+pymysql://", 1)
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> The database URL is like an address that tells your app how to connect. The "driver" is like choosing which vehicle to use to get there. This line makes sure the right vehicle (PyMySQL) is always specified, even if the address string forgot to mention it.</p>
</blockquote>
<p>One line. But it took me 45 minutes to figure out why my database wouldn't connect when the credentials were clearly correct.</p>
<h3>The Data Models</h3>
<p>Five models: User, Question, Session, Answer, Feedback.</p>
<p>The <code>Answer</code> model is the most complex — it stores everything about a single response:</p>
<pre><code class="language-python">class Answer(Base):
    transcript = Column(Text)
    answer_score = Column(Float)        # out of 40 — Groq grades this
    confidence_score = Column(Float)    # out of 30 — computed locally
    eye_contact_score = Column(Float)   # out of 30 — from face-api.js
    filler_word_count = Column(Integer)
    filler_word_breakdown = Column(JSON)  # {"um": 3, "like": 2}
    speaking_pace = Column(Float)         # WPM
    pause_count = Column(Integer)
    gaze_percentage = Column(Float)       # 0–100
    groq_feedback = Column(JSON)          # full Groq response
</code></pre>
<p>The total score (answer + confidence + eye contact) adds up to 100. Content matters most (40%), but delivery and presence both count significantly (30% each).</p>
<h3>JWT Authentication</h3>
<p>Standard JWT auth — register, login, protected routes. One detail that matters: token expiry is set to 7 days. For a practice platform where users return daily, forcing re-login after an hour would be annoying. 7 days is the right balance.</p>
<blockquote>
<p>💡 <strong>Simple version:</strong> JWT is like a temporary pass. When you log in, the server gives you a pass with an expiry date stamped on it. Every time you open the app, you show that pass instead of logging in again. After 7 days the pass expires and you log in once more.</p>
</blockquote>
<hr />
<h2>Phase 3 — The AI Evaluation Pipeline</h2>
<p>This is the core of MockVue and where most of the interesting engineering happened.</p>
<p>When a user submits an answer, three things need to happen:</p>
<ol>
<li><p>Transcribe the audio (Groq Whisper)</p>
</li>
<li><p>Grade the transcript against a rubric (Groq Llama 3.3 70B)</p>
</li>
<li><p>Compute confidence metrics (local calculation)</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/ea6adc05-57d8-45a9-9899-b0dc13c327e7.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 1: Audio Transcription</h3>
<p>I originally let the browser's Web Speech API handle transcription. It runs locally and is free. But it had two problems: it's unreliable on mobile, and it varies by browser. Some users were getting no transcript at all.</p>
<p>The solution: record the raw audio with MediaRecorder and send it to the backend for Whisper to transcribe:</p>
<pre><code class="language-python">if audio:
    client = Groq(api_key=api_key)
    transcription = client.audio.translations.create(
        file=(filename, audio_data),
        model="whisper-large-v3-turbo",
        response_format="verbose_json"
    )
    transcript = transcription.text.strip()
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> The browser tries to convert your speech to text in real time, but it often misses things. So instead I also record the actual audio file and send it to Whisper — OpenAI's dedicated speech-to-text model — on the server. Whisper is much more accurate, especially for accented English.</p>
</blockquote>
<p>The <code>verbose_json</code> format is important. It returns timestamps for each speech segment, which I use to compute pauses:</p>
<pre><code class="language-python">segments = getattr(transcription, "segments", [])
for i in range(1, len(segments)):
    if segments[i]["start"] - segments[i-1]["end"] &gt;= 3.0:
        current_pause_count += 1
</code></pre>
<p>Any gap longer than 3 seconds between speech segments counts as a long pause. Whisper gives me this for free.</p>
<h3>Step 2: Answer Grading with Llama</h3>
<pre><code class="language-python">user_prompt = f"""Interview Question: {question_text}

Rubric (total {total_points} points):
{rubric_text}

Student's Answer: {transcript}

Score each rubric point and provide specific feedback. Return JSON in exactly this format:
{{
  "rubric_scores": [
    {{"point": "rubric point text", "score": N, "max": N, "feedback": "specific feedback"}}
  ],
  "overall_feedback": "2-3 sentences of specific, actionable feedback",
  "summary": "one sentence summary of the answer quality",
  "total_answer_score": N
}}"""
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> I'm basically giving the AI a marking scheme and a student's answer, and asking it to fill in a scorecard. By telling it exactly what JSON structure to return, I can reliably parse the response in code.</p>
</blockquote>
<p>Three specific design choices here:</p>
<p><strong>Structured output via prompt engineering.</strong> I don't use Groq's JSON mode — I tell the model exactly what JSON structure to return in plain English. The fallback parser strips code blocks in case the model wraps the JSON in backticks anyway:</p>
<pre><code class="language-python">match = re.search(r'\{.*\}', raw, re.DOTALL)
if match:
    return json.loads(match.group())
</code></pre>
<p><strong>Low temperature (0.3).</strong> Interview grading should be consistent. I don't want the same answer to get a 28/40 one day and a 35/40 the next.</p>
<blockquote>
<p>💡 <strong>Simple version:</strong> "Temperature" in AI models controls how creative/random the output is. 0 = always the same answer. 1 = creative and unpredictable. For grading, I want 0.3 — consistent, but not robotically identical.</p>
</blockquote>
<p><strong>Graceful degradation.</strong> If Groq fails (rate limit, network error, invalid key), I return a fallback response with zero scores instead of crashing:</p>
<pre><code class="language-python">except Exception as e:
    return {
        "rubric_scores": [
            {"point": r["point"], "score": 0, "max": r["points"],
             "feedback": "Could not evaluate."}
            for r in rubric
        ],
        "overall_feedback": "Could not evaluate your answer at this time.",
        "total_answer_score": 0
    }
</code></pre>
<p>The user still gets their confidence and eye contact scores. Their session isn't lost. This kind of defensive programming matters in production.</p>
<h3>Step 3: Confidence Scoring</h3>
<p>This is computed entirely on the backend without any AI. I designed a custom scoring formula:</p>
<pre><code class="language-python">def compute_confidence_score(filler_count, wpm, pause_count):
    # Max 30 points total
    filler_score = max(0.0, 15.0 - filler_count * 1.5)  # 15 pts base

    if 120 &lt;= wpm &lt;= 150:
        wpm_score = 8.0                                  # 8 pts for ideal pace
    else:
        distance = min(abs(wpm - 120), abs(wpm - 150))
        wpm_score = max(0.0, 8.0 - distance * 0.1)

    pause_score = max(0.0, 7.0 - pause_count * 2.0)     # 7 pts base

    return round(filler_score + wpm_score + pause_score, 1)
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> Three things make you sound confident: not saying "um/uh/like" too much (15 pts), speaking at the right speed — 120 to 150 words per minute (8 pts), and not going silent for more than 3 seconds too often (7 pts). This function just does that math.</p>
</blockquote>
<p>The ideal speaking pace is 120–150 WPM — the range commonly cited for professional presentations. Too fast sounds nervous; too slow sounds unsure. The penalty function is smooth, not binary, so someone at 115 WPM isn't punished as harshly as someone at 80 WPM.</p>
<h3>The BYOK (Bring Your Own Key) Decision</h3>
<p>This was the most consequential product decision I made. MockVue requires users to provide their own Groq API key.</p>
<blockquote>
<p>💡 <strong>Simple version:</strong> Instead of paying for everyone's AI calls out of my own pocket, each user connects their own free Groq account. Groq gives every account a free usage quota, so each user gets their own limit instead of everyone sharing mine.</p>
</blockquote>
<p>Why? Because Groq gives every user a free tier with generous limits. If I ran all evaluations through a single API key, I'd hit rate limits within hours of a few users practicing. By having each user bring their own key, every user gets their own quota, and I pay $0 in API costs.</p>
<p>The key is verified before it's saved:</p>
<pre><code class="language-python">@router.post("/verify-api-key")
def verify_api_key(data: schemas.ApiKeyVerify):
    try:
        client = Groq(api_key=data.api_key)
        client.chat.completions.create(
            model="llama-3.3-70b-versatile",
            messages=[{"role": "user", "content": "ping"}],
            max_tokens=5
        )
        return {"success": True}
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"Invalid API Key: {str(e)}")
</code></pre>
<p>A tiny test call — 5 tokens — confirms the key works before saving it. If the key is invalid or the user is over quota, we tell them immediately instead of letting them discover it mid-interview.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/2bb4bca9-9b49-40ea-80a6-f5573f639a1f.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Phase 4 — The Frontend</h2>
<h3>The Interview Flow</h3>
<p>The interview has a deliberate flow built around real AI video assessment platforms:</p>
<pre><code class="language-plaintext">Setup Page → Camera Check → Interview Page → Processing → Feedback Report → Session Complete
</code></pre>
<p>Each transition is intentional. The Camera Check page verifies four things before allowing the user to start:</p>
<ol>
<li><p>Camera access and video feed</p>
</li>
<li><p>Microphone access and audio levels</p>
</li>
<li><p>face-api.js models loaded</p>
</li>
<li><p>Groq API key active (live test call)</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/66da6934-b626-4acc-96aa-dc7ca18f9a49.png" alt="" style="display:block;margin:0 auto" />

<p>If any of these fail, the user can't start. This prevents a situation where someone answers 5 questions and discovers their microphone was muted the whole time.</p>
<h3>The Reading Phase</h3>
<p>One detail that makes MockVue feel like a real assessment: the 30-second reading phase before recording starts. Real AI interview platforms give you reading time. I replicated this with a countdown timer and a beep at 10 seconds remaining:</p>
<pre><code class="language-javascript">const playBeep = () =&gt; {
    const ctx = new AudioContext();
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.connect(gain);
    gain.connect(ctx.destination);
    osc.frequency.value = 880;
    gain.gain.setValueAtTime(0.3, ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
    osc.start();
    osc.stop(ctx.currentTime + 0.3);
};
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> The Web Audio API lets you generate sound from scratch in the browser — no audio file needed. I create an oscillator (a tone generator), ramp the volume down quickly to get a sharp beep sound, and play it for 0.3 seconds. One beep = "recording starts in 10 seconds."</p>
</blockquote>
<p>Pure Web Audio API. No library needed for a simple beep.</p>
<h3>Real-Time Analytics</h3>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/ac276f66-5381-4891-8e6b-337578be0d5a.png" alt="" style="display:block;margin:0 auto" />

<p>While recording, the page tracks three things live:</p>
<p><strong>WPM tracking</strong> via Web Speech API:</p>
<pre><code class="language-javascript">recognition.onresult = (e) =&gt; {
    const combined = (finalTranscriptRef.current + interimTranscript).trim();
    wordCountRef.current = combined.split(/\s+/).filter(Boolean).length;

    FILLER_WORDS.forEach((fw) =&gt; {
        const matches = (lower.match(new RegExp(`\\b${fw}\\b`, 'g')) || []).length;
        if (matches &gt; 0) { breakdown[fw] = matches; }
    });
};
</code></pre>
<p><strong>Pause detection</strong> using silence gaps in speech recognition events.</p>
<p><strong>Live WPM display</strong> in the corner of the recording interface. Seeing your words-per-minute update in real time while speaking is genuinely useful for self-awareness. I thought it was a gimmick at first. After testing, it became one of my favourite features.</p>
<h3>Eye Contact — The Hard Part</h3>
<p>This was the most technically complex part of the entire project.</p>
<blockquote>
<p>💡 <strong>Simple version:</strong> face-api.js is a library that looks at your webcam video and finds faces in it. I use it to figure out whether you're looking at the camera or looking away, and track what percentage of your recording time you spent looking at the camera.</p>
</blockquote>
<p>face-api.js is a TensorFlow.js-based library that can detect faces and landmarks in a browser video feed. I use two models: TinyFaceDetector (fast, small) and FaceLandmark68TinyNet (68 facial landmarks).</p>
<p>The naive implementation would be: "is a face detected? yes → looking at camera." But that's wrong. Someone looking down at notes has their face in frame but is clearly not looking at the camera.</p>
<p>The better approach: use facial landmarks to estimate head orientation. Specifically, I use the nose tip and eye positions to compute a lateral ratio:</p>
<pre><code class="language-javascript">const eyeSpan = rightEye[3].x - leftEye[0].x;
const noseOffset = nose[0].x - leftEye[0].x;
const ratio = noseOffset / eyeSpan;

// Ratio ~0.5 = nose is centered between eyes = facing forward
const isFrontal = ratio &gt; 0.35 &amp;&amp; ratio &lt; 0.65;
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> When you look straight at the camera, your nose tip is roughly halfway between your two eyes (horizontally). When you look left or right, the nose appears to "shift" toward one eye. I measure this shift — if the nose is between 35% and 65% across the eye span, you're looking at the camera. If it's outside that range, you're looking away.</p>
</blockquote>
<p><strong>Problem: face-api.js model files</strong></p>
<p>The models are binary weight files (~1–3 MB each) that need to be served as static assets. I couldn't import them from npm — I had to download them and put them in <code>public/models/</code>. I wrote a Node.js download script for this:</p>
<pre><code class="language-javascript">const FILES = [
    'tiny_face_detector_model-weights_manifest.json',
    'tiny_face_detector_model-shard1',
    'face_landmark_68_tiny_model-weights_manifest.json',
    'face_landmark_68_tiny_model-shard1',
];
</code></pre>
<p>Anyone cloning the repo needs to run this script once before starting the frontend. I missed this in my first README draft and got confused when models silently failed to load on a fresh machine.</p>
<p><strong>Problem: macOS Safari video readyState</strong></p>
<p>On Safari, <code>video.readyState</code> can stay at 1 (HAVE_METADATA) even when the video looks like it's playing. The face detection interval was running but the video element wasn't actually producing pixel data yet, so every frame returned null.</p>
<blockquote>
<p>💡 <strong>Simple version:</strong> <code>readyState</code> is the video's way of saying how ready it is. State 1 means "I know the video exists." State 2 means "I have actual frames to show you." Safari was stuck at 1, so when face detection asked "what does the video look like right now?" the answer was "nothing." Fix: only run detection when readyState is at least 2.</p>
</blockquote>
<p>Fix: check <code>readyState &gt;= 2</code> before running detection, and force-call <code>video.play()</code> in the interval callback as a safety measure.</p>
<p><strong>Problem: gaze percentage accuracy</strong></p>
<p>Early testing showed gaze percentages of 20–40% for people clearly looking at the camera. I dropped the face detection score threshold from <code>0.5</code> to <code>0.2</code> and expanded the frontal ratio window from <code>0.4–0.6</code> to <code>0.35–0.65</code>. After this, numbers for someone looking directly at the camera consistently landed in the 75–90% range.</p>
<hr />
<h2>Phase 5 — The Camera Check Page</h2>
<p>The Camera Check page looks simple but has a lot of defensive code underneath.</p>
<p>Getting camera and microphone access in a browser is surprisingly fragile. Different operating systems, browsers, and hardware all behave differently. I went through four iterations before the hardware probe logic became reliable:</p>
<pre><code class="language-javascript">try {
    // Attempt 1: Combined request
    stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
} catch (probeErr) {
    // Attempt 2: Split request
    stream = await navigator.mediaDevices.getUserMedia({ video: true });
    try {
        const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
        stream.addTrack(audioStream.getAudioTracks()[0]);
    } catch (audioErr) {
        // Attempt 3: Raw audio — bypasses strict macOS CoreAudio constraints
        const rawAudio = await navigator.mediaDevices.getUserMedia({
            audio: { echoCancellation: false, noiseSuppression: false }
        });
        stream.addTrack(rawAudio.getAudioTracks()[0]);
    }
}
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> Asking for camera and microphone permission can fail in several ways. Instead of giving up on the first failure, I try three progressively simpler requests. The last attempt disables audio processing features (echo cancellation, noise reduction) because macOS sometimes blocks the request when those are turned on and another app is already using the mic.</p>
</blockquote>
<p>There's also device selection — dropdowns for switching between multiple cameras or microphones. One subtle point: <code>enumerateDevices()</code> doesn't show device labels until the user has already granted permission. Get this order wrong and all devices show as "Camera 1", "Microphone 2" with no useful labels.</p>
<blockquote>
<p>💡 <strong>Simple version:</strong> For privacy reasons, your browser won't tell a website the names of your cameras and microphones until you've already said "yes" to the permission prompt. So the flow must be: ask permission first → then list devices with their real names. Doing it the other way round gets you blank labels.</p>
</blockquote>
<hr />
<h2>Phase 6 — Deployment and Cloud Architecture</h2>
<h3>Frontend on Vercel</h3>
<p>The frontend deployment was the easiest part. Push to GitHub, connect to Vercel, set the <code>VITE_API_BASE_URL</code> environment variable to the Render URL, done. The only non-obvious config was <code>vercel.json</code>:</p>
<pre><code class="language-json">{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> React apps have one HTML file (<code>index.html</code>) and React handles all the different "pages" in JavaScript. But if you directly visit <code>/dashboard</code> in the browser, Vercel looks for a file called <code>dashboard.html</code>, doesn't find it, and returns a 404. This config tells Vercel: "for any URL, just load <code>index.html</code> and let React figure out the rest."</p>
</blockquote>
<h3>Backend on Render</h3>
<p>Render's free tier has a cold start problem. If no requests come in for 15 minutes, the service spins down. The next request takes 30–50 seconds while the server wakes up.</p>
<p>I handled this with a "waking up" overlay that detects slow initial connections:</p>
<pre><code class="language-javascript">const timeout = setTimeout(() =&gt; {
    setWakingUp(true);
}, 2500);

api.get('/').then(() =&gt; {
    clearTimeout(timeout);
    setWakingUp(false);
});
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> If the backend doesn't respond within 2.5 seconds, I assume it's asleep and show a friendly message explaining the wait. If it responds quickly, the message never appears. This stops users from thinking the app is broken — they know it's just warming up.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/aa5083a1-11e7-41eb-bac6-fdb63c056321.png" alt="" style="display:block;margin:0 auto" />

<p>If the backend doesn't respond within 2.5 seconds, a friendly "we're on the free tier, this takes ~40 seconds" message appears with a progress bar. Users don't rage-quit; they wait. Honest communication about infrastructure limitations is a UX choice.</p>
<p>The database connection also had a cold start issue. Without the <code>pool_pre_ping</code> option, the first database query after a server wakeup fails with "MySQL server has gone away":</p>
<pre><code class="language-python">engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,
    connect_args=connect_args
)
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> SQLAlchemy keeps a pool of open database connections ready to use. But after the server sleeps and wakes up, those old connections are dead — the database closed them. <code>pool_pre_ping=True</code> tells SQLAlchemy to test each connection before using it, and automatically create a fresh one if the old one is dead.</p>
</blockquote>
<hr />
<h2>Phase 7 — The Bugs</h2>
<p>Here's every significant bug I hit and how I fixed it.</p>
<p><strong>Bug 1: CORS errors on first deployment</strong></p>
<p>The frontend was sending <code>Authorization: Bearer &lt;token&gt;</code> headers. CORS preflight requests for credentialed requests are handled differently and were getting blocked.</p>
<p>Fix: Make sure <code>allow_credentials</code> and <code>allow_origins</code> are compatible — you can't use <code>["*"]</code> for origins with <code>allow_credentials=True</code> simultaneously.</p>
<blockquote>
<p>💡 <strong>Simple version:</strong> CORS is a browser security feature that asks the server "is it okay if this website talks to you?" When your request carries a login token, the browser asks this question even more strictly. Getting the server's CORS settings slightly wrong causes the browser to block the request entirely, even though the server itself would have been happy to respond.</p>
</blockquote>
<p><strong>Bug 2: MediaRecorder codec mismatch on iOS Safari</strong></p>
<p>On iOS, <code>audio/webm</code> is not supported by MediaRecorder. The recording silently produced an empty blob.</p>
<pre><code class="language-javascript">let mimeType = 'audio/webm';
if (!MediaRecorder.isTypeSupported(mimeType)) {
    mimeType = 'audio/mp4';
}
</code></pre>
<p>The file extension sent to Groq Whisper also needs to match the actual format:</p>
<pre><code class="language-javascript">let ext = 'webm';
if (window.mv_audio_blob.type.includes('mp4')) ext = 'mp4';
formData.append('audio', window.mv_audio_blob, `audio.${ext}`);
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> Different browsers record audio in different file formats — like how some cameras save as JPEG, others as PNG, others as HEIC. Whisper needs to know the format to decode it. If the file says it's <code>.webm</code> but it's actually <code>.mp4</code> inside, Whisper rejects it. This took two hours to debug because the failure was completely silent — no error, just no transcript.</p>
</blockquote>
<p><strong>Bug 3: React StrictMode double-mount submitting answers twice</strong></p>
<p>In React 18+ with StrictMode, every <code>useEffect</code> runs twice on mount in development. My Processing page was calling the answer submission API twice, creating duplicate records.</p>
<p>Fix: A ref guard:</p>
<pre><code class="language-javascript">const hasSubmitted = useRef(false);

useEffect(() =&gt; {
    if (!hasSubmitted.current) {
        hasSubmitted.current = true;
        submitAnswer();
    }
}, []);
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> React's "Strict Mode" deliberately runs your setup code twice in development to help catch bugs. Usually harmless — but if your setup code calls an API, it sends the request twice. A <code>useRef</code> variable persists across both runs, so I use it as a "has this already run?" flag. <code>useState</code> doesn't work here because React resets state between the two runs.</p>
</blockquote>
<p><strong>Bug 4: TiDB Cloud connection timing out on Render cold start</strong></p>
<p>The database connection would succeed locally but time out on Render after cold start.</p>
<pre><code class="language-python">connect_args["connect_timeout"] = 10
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> By default, SQLAlchemy waits forever for a database connection to succeed. On Render, after a cold start, the database might take a few seconds to accept connections. Without a timeout, if anything goes wrong, the request just hangs forever instead of failing and letting you retry. 10 seconds is generous enough to handle slow wakeups but short enough to fail fast if something is actually broken.</p>
</blockquote>
<p><strong>Bug 5: face-api.js models loading race condition</strong></p>
<p>The gaze detection interval would start before the models finished loading and throw errors on every frame.</p>
<p>Fix: Always check <code>faceapi.nets.tinyFaceDetector.isLoaded</code> at the start of the detection interval:</p>
<pre><code class="language-javascript">if (!faceapi.nets.tinyFaceDetector.isLoaded) return; // skip this frame
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> The AI models are downloaded from the server asynchronously in the background. But I was starting the detection interval immediately. So for the first few seconds, the interval was running and asking the AI to analyze frames before the AI model had even finished downloading. The fix: just skip any frame where the model isn't ready yet.</p>
</blockquote>
<p><strong>Bug 6: Session score showing 0 mid-interview</strong></p>
<p>The session's <code>overall_score</code> was only calculated when the session was marked "complete." Users checking their dashboard mid-interview would see a score of 0.</p>
<p>Fix: Recalculate and update the session score in real-time every time an answer is submitted:</p>
<pre><code class="language-python">answers = db.query(models.Answer).filter(models.Answer.session_id == session_id).all()
if answers:
    total = sum((a.answer_score or 0) + (a.confidence_score or 0) + (a.eye_contact_score or 0)
                for a in answers)
    session.overall_score = round(total / len(answers), 1)
</code></pre>
<p><strong>Bug 7: Whisper returning empty transcript for short answers</strong></p>
<p>If a user spoke for less than 2 seconds, Whisper sometimes returned an empty string.</p>
<p>Fix: Fall back to the browser's Web Speech API transcript if Whisper returns empty:</p>
<pre><code class="language-python">result_text = transcription.text.strip()
if result_text:
    transcript = result_text
# else: keep the frontend transcript already in the form data
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> I always send two versions of the transcript to the server: one from the browser's built-in speech recognition (sent as a form field), and one from Whisper (generated on the server from the audio file). If Whisper returns nothing, I use the browser's version as backup. Having two independent sources means something always goes through.</p>
</blockquote>
<hr />
<h2>Phase 8 — The Feedback Report</h2>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/49f5b72d-2b19-4e4d-ade8-c0d77c16284d.png" alt="" style="display:block;margin:0 auto" />

<p>Every MockVue score adds up to 100:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Max</th>
<th>How it's calculated</th>
</tr>
</thead>
<tbody><tr>
<td>Answer Quality</td>
<td>40</td>
<td>Groq Llama grades against rubric</td>
</tr>
<tr>
<td>Confidence</td>
<td>30</td>
<td>Filler words (15) + WPM (8) + Pauses (7)</td>
</tr>
<tr>
<td>Eye Contact</td>
<td>30</td>
<td><code>gaze_percentage × 0.3</code></td>
</tr>
</tbody></table>
<p>The feedback report breaks down every dimension with specific callouts. The transcript is highlighted — filler words in amber, quality buzzwords in green. The WPM gauge shows pace against the 120–150 ideal zone. The gaze timeline shows a visual representation of camera presence across the recording.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/c0a6e317-767f-4c99-afba-64a1f5e5205c.png" alt="" style="display:block;margin:0 auto" />

<p>One thing I'm proud of: the priority tip on the Session Complete page. After your session, the system identifies which dimension you scored lowest on proportionally and gives you a specific practice recommendation:</p>
<pre><code class="language-javascript">const lowestArea =
    Math.min(avgAnswer / 40, avgConfidence / 30, avgGaze / 30) === avgAnswer / 40
        ? { area: 'Answer Quality', tip: 'Focus on the STAR method...' }
        : Math.min(avgConfidence / 30, avgGaze / 30) === avgConfidence / 30
        ? { area: 'Confidence', tip: 'Practise out loud daily...' }
        : { area: 'Eye Contact', tip: 'Place a sticker dot above your camera...' };
</code></pre>
<blockquote>
<p>💡 <strong>Simple version:</strong> Raw scores aren't comparable — 20/40 on answers isn't the same as 20/30 on eye contact. So I convert each score to a percentage of its maximum (answer: /40, confidence: /30, eye contact: /30) before comparing. The lowest percentage tells me which area genuinely needs the most work.</p>
</blockquote>
<hr />
<h2>What I'd Do Differently</h2>
<p><strong>1. Use a job queue for AI processing.</strong> Right now the answer submission endpoint is synchronous — it calls Whisper, then Llama, then saves to database, all in one request. On Render's free tier this takes 8–15 seconds while the connection hangs. A proper solution would queue the AI processing and let the frontend poll for results.</p>
<p><strong>2. Add rate limiting.</strong> The <code>/auth/register</code> endpoint has no rate limiting. A bot could create thousands of accounts. Libraries like <code>slowapi</code> for FastAPI make this a 10-minute addition.</p>
<p><strong>3. Store recordings temporarily.</strong> Right now the audio blob is processed and discarded. Storing it for 24 hours in S3 would let users replay their answers alongside the transcript — significantly more useful for self-improvement.</p>
<p><strong>4. Calibrate eye tracking per user.</strong> The nose-to-eye ratio works for most setups but breaks if someone's camera is off-center or they have an unusual setup. A brief calibration step at the Camera Check page would make scores more accurate.</p>
<p><strong>5. Ship the feedback report first.</strong> I built the scoring system last, but it's the most important thing from a user perspective. I should have designed the feedback report first and worked backwards to figure out what data I needed to collect. I wasted time building features that didn't contribute to the quality of the feedback.</p>
<hr />
<h2>Key Takeaways</h2>
<p><strong>The BYOK model is underrated.</strong> Making users bring their own API keys is usually seen as friction. For this use case, it was the right call. Every user gets their own rate limit, infrastructure costs stay at $0, and the app can scale without me paying per-evaluation.</p>
<p><strong>Defensive code is worth every line.</strong> The three-attempt hardware probe, the Whisper fallback, the <code>pool_pre_ping</code>, the <code>hasSubmitted</code> ref guard — none of these are in tutorials. They all came from real failures. Every edge case I handled made the app more trustworthy.</p>
<p><strong>Face detection in the browser is doable but finicky.</strong> face-api.js is mature, but integrating it with MediaRecorder and real-time React state requires care. The key insight: run it in a <code>setInterval</code>, not in React's rendering cycle. Keep all heavy computation in refs.</p>
<p><strong>Honest UI for free-tier limitations is good UX.</strong> Instead of hiding the cold start problem, I surfaced it with a friendly message. Users understood. They waited. Nobody complained about the 40-second wakeup time in feedback — they complained about things I could actually fix.</p>
<p><strong>Real projects break in real ways.</strong> Every tutorial shows you the happy path. Building MockVue meant hitting SSL certificate paths, iOS codec incompatibilities, React StrictMode double-mounts, browser permission ordering requirements, and model loading race conditions. Debugging these is the actual job of a developer.</p>
<hr />
<h2>Resources</h2>
<ul>
<li><p><strong>Live app:</strong> <a href="https://mock-vue.vercel.app">mock-vue.vercel.app</a></p>
</li>
<li><p><strong>GitHub:</strong> <a href="https://github.com/shlokbam/MockVue">https://github.com/shlokbam/MockVue</a></p>
</li>
<li><p><strong>Groq API (free):</strong> <a href="https://console.groq.com">console.groq.com</a></p>
</li>
<li><p><strong>face-api.js:</strong> <a href="https://github.com/vladmandic/face-api">github.com/vladmandic/face-api</a></p>
</li>
<li><p><strong>TiDB Cloud:</strong> <a href="https://tidbcloud.com">tidbcloud.com</a></p>
</li>
<li><p><strong>FastAPI docs:</strong> <a href="https://fastapi.tiangolo.com">fastapi.tiangolo.com</a></p>
</li>
</ul>
<p>If you built something similar, hit a different bug, or want to extend MockVue — let me know in the comments. Would love to compare notes.</p>
]]></content:encoded></item><item><title><![CDATA[I Built an AI Data Analyst App from Scratch — Here's How I Taught a Flask App to Think]]></title><description><![CDATA[A full walkthrough of building DataLens — CSV uploads, Groq/Llama AI insights, auto-generated charts, user auth, persistent chat history, and PDF export. Including every bug, every "why is this broken]]></description><link>https://shlokbam.hashnode.dev/i-built-an-ai-data-analyst-app-from-scratch-here-s-how-i-taught-a-flask-app-to-think</link><guid isPermaLink="true">https://shlokbam.hashnode.dev/i-built-an-ai-data-analyst-app-from-scratch-here-s-how-i-taught-a-flask-app-to-think</guid><dc:creator><![CDATA[Shlok Bam]]></dc:creator><pubDate>Wed, 25 Mar 2026 20:24:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/05cab02a-abc0-4fae-9dc9-e35b31e82bbd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>A full walkthrough of building DataLens — CSV uploads, Groq/Llama AI insights, auto-generated charts, user auth, persistent chat history, and PDF export. Including every bug, every "why is this broken" moment, and every concept explained properly.</em></p>
<hr />
<h2>Before We Start — Why I Built This</h2>
<p>I've been getting into AI APIs lately. And like most people who just discovered that you can call a language model from Python in three lines of code, I immediately wanted to do something actually useful with it.</p>
<p>The idea came from a real frustration. I had a sales CSV with 2,800 rows. I wanted to know which region was performing best, what the trend looked like over time, and whether there was a correlation between deal size and product line. I opened Excel, filtered, aggregated, made a pivot table, screamed internally, and gave up.</p>
<p>What if I could just <em>ask</em> those questions in plain English and get an actual answer?</p>
<p>So I built DataLens — an app where you upload any CSV, ask questions in natural language, get AI-powered insights, and get automatically generated charts. Then I kept going. Added user accounts. Saved conversation history. Added PDF export.</p>
<p>This post covers the full build — every phase, every concept, every error that made me question my choices. If you're learning Flask, SQLAlchemy, or working with AI APIs, there's something here for you.</p>
<hr />
<h2>What I Built</h2>
<p>Here's what DataLens does:</p>
<pre><code class="language-plaintext">User uploads CSV
    │
    ▼ 
Flask reads the file → Pandas generates a text summary
    │
    ▼
Groq API (Llama 3.3 70B) reads the summary → generates insight
    │
    ▼
Groq suggests chart type + which columns to plot
    │
    ▼
Matplotlib renders the chart → PNG sent directly to browser
    │
    ▼
SQLAlchemy saves the Q&amp;A to the database
    │
    ▼
User can switch between past chats, export PDFs
</code></pre>
<p>Every question you ask is saved. Every analysis session is stored. You can close the tab, come back tomorrow, and pick up exactly where you left off. And when you're done, you can export the whole conversation — questions, AI answers, and charts — as a PDF.</p>
<p>Tech stack:</p>
<table>
<thead>
<tr>
<th>What</th>
<th>Tool</th>
</tr>
</thead>
<tbody><tr>
<td>Web Framework</td>
<td>Flask</td>
</tr>
<tr>
<td>Database</td>
<td>SQLAlchemy + SQLite</td>
</tr>
<tr>
<td>Auth</td>
<td>Flask-Login</td>
</tr>
<tr>
<td>AI</td>
<td>Groq API (Llama 3.3 70B)</td>
</tr>
<tr>
<td>Data Processing</td>
<td>Pandas</td>
</tr>
<tr>
<td>Charting</td>
<td>Matplotlib</td>
</tr>
<tr>
<td>PDF Generation</td>
<td>ReportLab</td>
</tr>
<tr>
<td>Frontend</td>
<td>Vanilla JS + CSS</td>
</tr>
</tbody></table>
<p>I built this in 4 phases. Let me walk you through each one.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/a391da98-3751-4c41-99c5-a7e7e4e47802.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Phase A — SQLAlchemy + Database Design</h2>
<p>The first decision was the data model. Three tables:</p>
<ul>
<li><p><strong>User</strong> — email and hashed password</p>
</li>
<li><p><strong>Chat</strong> — each CSV upload creates a Chat (stores the filename and path)</p>
</li>
<li><p><strong>Message</strong> — each Q&amp;A exchange is a Message inside a Chat</p>
</li>
</ul>
<p>This is a classic one-to-many relationship:</p>
<ul>
<li><p>One User → many Chats</p>
</li>
<li><p>One Chat → many Messages</p>
</li>
</ul>
<p>Here's how that looks in SQLAlchemy:</p>
<pre><code class="language-python">class User(db.Model, UserMixin):
    __tablename__ = 'users'
    id            = db.Column(db.Integer, primary_key=True)
    email         = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(256), nullable=False)
    chats         = db.relationship('Chat', backref='user', lazy=True,
                                    cascade='all, delete-orphan')

class Chat(db.Model):
    __tablename__ = 'chats'
    id           = db.Column(db.Integer, primary_key=True)
    name         = db.Column(db.String(200), nullable=False)
    csv_path     = db.Column(db.String(500))
    csv_filename = db.Column(db.String(200))
    user_id      = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    messages     = db.relationship('Message', backref='chat', lazy=True,
                                   cascade='all, delete-orphan')

class Message(db.Model):
    __tablename__ = 'messages'
    id          = db.Column(db.Integer, primary_key=True)
    chat_id     = db.Column(db.Integer, db.ForeignKey('chats.id'), nullable=False)
    question    = db.Column(db.Text, nullable=False)
    answer      = db.Column(db.Text, nullable=False)
    chart_type  = db.Column(db.String(50))
    chart_x_col = db.Column(db.String(200))
    chart_y_col = db.Column(db.String(200))
</code></pre>
<p>A few things here that are worth understanding:</p>
<p><code>cascade='all, delete-orphan'</code> — when you delete a User, all their Chats get deleted automatically. When you delete a Chat, all its Messages go too. Without this, you'd have orphaned rows sitting in the database forever.</p>
<p><code>backref='user'</code> — this creates a reverse relationship. Once this is set, you can do <code>chat.user</code> to get the User who owns that chat, without writing any extra query. SQLAlchemy handles it.</p>
<p><code>UserMixin</code> — Flask-Login needs certain methods on your User model (<code>is_authenticated</code>, <code>get_id()</code>, etc.). <code>UserMixin</code> provides all of these for free. You just inherit from it.</p>
<p>No separate migration tool needed for this project. Just <code>db.create_all()</code> inside the app context on startup, and all three tables get created automatically.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/54582de4-5f7e-44f4-b11b-1bd17c49c4ac.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Phase B — Flask Blueprints + Auth</h2>
<p>This is where I learned what Blueprints actually are, not just theoretically.</p>
<p>A Blueprint is Flask's way of splitting a large app into smaller, reusable pieces. Instead of dumping everything in <code>app.py</code>, you put auth-related routes in <code>auth.py</code> as a Blueprint and register it in <code>app.py</code>. The routes behave identically — they're just organized.</p>
<pre><code class="language-python"># auth.py
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user

auth_bp = Blueprint('auth', __name__)

@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('index'))

    if request.method == 'POST':
        email    = request.form.get('email', '').strip().lower()
        password = request.form.get('password', '')

        user = User.query.filter_by(email=email).first()

        if not user or not user.check_password(password):
            flash('Invalid email or password.', 'error')
            return render_template('login.html')

        login_user(user, remember=True)
        return redirect(url_for('index'))

    return render_template('login.html')
</code></pre>
<pre><code class="language-python"># app.py
from auth import auth_bp
app.register_blueprint(auth_bp)
</code></pre>
<p>That's it. The route lives at <code>/login</code> and you reference it anywhere as <code>url_for('auth.login')</code>. The <code>'auth.'</code> prefix is the Blueprint name. One of those things where once you see it, it clicks immediately.</p>
<p><strong>Password hashing</strong> — I ran into a compatibility issue here. Werkzeug 2.x defaults to <code>scrypt</code> for hashing. But <code>scrypt</code> requires OpenSSL compiled with scrypt support, and my Python 3.9 environment didn't have it:</p>
<pre><code class="language-plaintext">AttributeError: module 'hashlib' has no attribute 'scrypt'
</code></pre>
<p>Fix was simple — explicitly specify <code>pbkdf2:sha256</code>:</p>
<pre><code class="language-python">def set_password(self, password):
    self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
</code></pre>
<p><code>pbkdf2:sha256</code> is NIST-approved, used by production apps everywhere, and works on all Python versions. Perfectly fine security-wise.</p>
<p><strong>Protecting routes</strong> — one decorator and a route is fully protected:</p>
<pre><code class="language-python">@app.route('/upload', methods=['POST'])
@login_required
def upload_file():
    ...
</code></pre>
<p>Unauthenticated requests get redirected to the login page automatically. Just make sure you tell Flask-Login where your login page is:</p>
<pre><code class="language-python">login_manager.login_view = 'auth.login'
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/6031391a-7ab9-45c1-afe2-45992cb9f2c8.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Phase C — Multi-Chat Routing</h2>
<p>Here's where it got interesting.</p>
<p>The original version of the app was stateless — you uploaded a file, asked questions, everything lived in the Flask session (basically a browser cookie). Close the tab and it was gone. Not great.</p>
<p>Phase C converts it to full persistence. Every upload creates a Chat row. Every question creates a Message row. The user's sidebar shows all their past analyses.</p>
<pre><code class="language-python">@app.route('/upload', methods=['POST'])
@login_required
def upload_file():
    file = request.files['file']
    filename = secure_filename(file.filename)
    filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
    file.save(filepath)

    # Phase C: create a Chat record in the database
    chat = Chat(
        name=filename.replace('.csv', '').replace('_', ' ').title(),
        csv_path=filepath,
        csv_filename=filename,
        user_id=current_user.id
    )
    db.session.add(chat)
    db.session.commit()

    session['filepath'] = filepath
    session['chat_id']  = chat.id

    # ... return file info
</code></pre>
<p>And in the <code>/ask</code> route, after getting the AI response:</p>
<pre><code class="language-python">msg = Message(
    chat_id     = session.get('chat_id'),
    question    = user_question,
    answer      = insight,
    chart_type  = chart_type,
    chart_x_col = chart_column_suggestion.get('x'),
    chart_y_col = chart_column_suggestion.get('y'),
)
db.session.add(msg)
db.session.commit()
</code></pre>
<p>We save the chart metadata too — not the image bytes, because charts can be regenerated from the original CSV later. This matters a lot for the PDF export in Phase D.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/57fb7709-1db1-41f8-9e2a-b74de2276975.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/bf2a85a9-d6ee-4a75-9800-0c1d41c9c37b.png" alt="" style="display:block;margin:0 auto" />

<p>The chat-switching API has three routes:</p>
<pre><code class="language-python">GET    /chats              # list all chats for current user
GET    /chats/&lt;id&gt;         # get all messages for one chat
POST   /chats/&lt;id&gt;/activate # restore a chat into the session
DELETE /chats/&lt;id&gt;         # delete chat + cascade messages
</code></pre>
<p>The frontend sidebar calls <code>/chats</code> on page load, renders a list, and when you click a past chat it calls <code>/chats/&lt;id&gt;/activate</code> (to restore the session filepath) then <code>/chats/&lt;id&gt;</code> (to load the messages). Old Q&amp;A cards get re-rendered and charts reload from the CSV.</p>
<p><strong>One thing I discovered</strong>: <code>db.session.get(User, user_id)</code> is the correct way to look up by primary key in SQLAlchemy 2.x. The old <code>User.query.get(id)</code> syntax still works but fires a deprecation warning on every request:</p>
<pre><code class="language-plaintext">LegacyAPIWarning: The Query.get() method is considered legacy
</code></pre>
<p>Changed it in the Flask-Login user loader and the warnings went away.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/61f3e2a2-5cfe-4c6f-843f-7a8fdddc5f90.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Phase D — PDF Export with ReportLab</h2>
<p>This was the most satisfying phase to build.</p>
<p>ReportLab is a Python library that gives you full programmatic control over PDF layout. No templates, no HTML-to-PDF conversion — you build every element from scratch in Python code.</p>
<p>The mental model is simple: ReportLab has a <code>story</code> — a list of <code>Flowable</code> objects that get laid out onto pages in order. You build the list, call <code>doc.build(story)</code>, and the library handles page breaks, margins, and layout.</p>
<pre><code class="language-python">from reportlab.platypus import (
    SimpleDocTemplate, Paragraph, Spacer,
    Image, HRFlowable, PageBreak
)

buf = io.BytesIO()

doc = SimpleDocTemplate(buf, pagesize=A4,
    leftMargin=25*mm, rightMargin=25*mm,
    topMargin=20*mm, bottomMargin=20*mm)

story = []

# Title page
story.append(Spacer(1, 30*mm))
story.append(Paragraph('DataLens', title_style))
story.append(Paragraph(chat.name, subtitle_style))
story.append(HRFlowable(width='100%', thickness=1, color=accent_color))
story.append(PageBreak())

# Q&amp;A sections
for msg in messages:
    story.append(Paragraph(msg.question, question_style))
    story.append(Paragraph(msg.answer, answer_style))

    if msg.chart_type != 'none':
        chart_buf = regenerate_chart(msg)
        story.append(Image(chart_buf, ...))

doc.build(story)
buf.seek(0)
return buf
</code></pre>
<p>The charts are re-generated on-the-fly — I pass a <code>chart_generator</code> closure into <code>build_pdf()</code> that reads the original CSV and rerenders the chart as a PNG. This is clean because no image bytes are stored in the database.</p>
<p>The export route itself is simple:</p>
<pre><code class="language-python">@app.route('/export/&lt;int:chat_id&gt;')
@login_required
def export_pdf(chat_id):
    chat = Chat.query.filter_by(id=chat_id, user_id=current_user.id).first_or_404()

    pdf_buf = build_pdf(chat, list(chat.messages), chart_generator)

    return send_file(pdf_buf,
        mimetype='application/pdf',
        as_attachment=True,
        download_name=f'datalens_{chat.name.lower().replace(" ", "_")}.pdf')
</code></pre>
<p><code>as_attachment=True</code> adds <code>Content-Disposition: attachment</code> to the response — that's the HTTP header that tells the browser to download the file instead of trying to display it inline.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/1395254f-8ac5-49ed-8986-afba38c88ae1.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/17d71ed0-10cb-4604-ac86-8f39777cffac.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>The AI Part — How It Actually Works</h2>
<p>Most of the "magic" is in <code>gemini_helper.py</code> (badly named — it actually uses the Groq API, not Google Gemini, but I kept the filename to avoid breaking imports).</p>
<p>The key insight: I never send the full CSV to the AI. Sending 2,800 rows to a language model would blow past the context limit, cost tokens, and be slow. Instead, I pre-process the CSV into a compact text summary:</p>
<pre><code class="language-plaintext">Shape: 2823 rows × 25 columns

Column Types:
  Numeric: QUANTITYORDERED, PRICEEACH, SALES, MSRP
  Categorical: STATUS, PRODUCTLINE, COUNTRY, TERRITORY

Statistics (numeric columns):
  SALES: mean=3553.89, std=1841.87, min=482.13, max=14082.80

Top Values:
  PRODUCTLINE: Classic Cars (967), Vintage Cars (607), Motorcycles (331)
  COUNTRY: USA (1004), Spain (342), France (314)

Missing Values: None

Sample Rows:
  ORDERNUMBER  SALES  PRODUCTLINE  COUNTRY
  10107        2871   Motorcycles  USA
  ...
</code></pre>
<p>This summary — not the raw CSV — gets sent to the AI. It's maybe 800 tokens vs. tens of thousands. The model can answer most analytical questions accurately from this structured summary.</p>
<p>Three separate AI calls happen for each question:</p>
<ol>
<li><p><code>get_ai_insight()</code> — the main call. Gets the text answer. Includes the last 5 exchanges as context so follow-up questions work properly.</p>
</li>
<li><p><code>suggest_chart_type()</code> — a separate call with <code>temperature=0</code> (deterministic). Returns exactly one word: <code>bar</code>, <code>line</code>, <code>scatter</code>, <code>histogram</code>, <code>pie</code>, or <code>none</code>. Low temperature because I need a parseable response, not creativity.</p>
</li>
<li><p><code>suggest_chart_columns()</code> — another separate call. Returns JSON with <code>x</code> and <code>y</code> column names. I parse this, validate against the actual column list, and fall back to sensible defaults if the AI halluccinates a column name that doesn't exist.</p>
</li>
</ol>
<p>Why three calls instead of one? When I tried to get everything in one call, the AI would sometimes get distracted and return malformed JSON, or mix the chart suggestion into the text answer. Separating concerns made each call simpler and more reliable.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/98d4fd31-659c-4a29-b1db-f36cadf6fb9a.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/d28c4b28-a90c-4d65-9ad3-00791c24912d.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Everything That Went Wrong — Summary</h2>
<table>
<thead>
<tr>
<th>Problem</th>
<th>Cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>hashlib has no attribute 'scrypt'</code></td>
<td>Python 3.9 missing scrypt support</td>
<td>Explicitly use <code>method='pbkdf2:sha256'</code> in <code>generate_password_hash</code></td>
</tr>
<tr>
<td>Upload returning 500</td>
<td>CSV with non-UTF-8 characters</td>
<td><code>try: pd.read_csv(f)</code> <code>except UnicodeDecodeError: pd.read_csv(f, encoding='latin1')</code></td>
</tr>
<tr>
<td>Data preview table blank</td>
<td>Pandas NaN serializes as bare <code>NaN</code> — invalid JSON</td>
<td><code>df.where(pd.notnull(df), None)</code> before <code>to_dict()</code></td>
</tr>
<tr>
<td><code>LegacyAPIWarning</code> on every request</td>
<td><code>User.query.get()</code> deprecated in SQLAlchemy 2.x</td>
<td>Replace with <code>db.session.get(User, user_id)</code></td>
</tr>
<tr>
<td>Auth routes returning 404</td>
<td>Thought Blueprint was at <code>/auth/login</code></td>
<td>Routes are at <code>/login</code> — no prefix. <code>url_for('auth.login')</code> still works</td>
</tr>
<tr>
<td>Chart generator silent failure in PDF</td>
<td>CSV no longer on disk when exporting old chat</td>
<td>Added early check <code>if not os.path.exists(chat.csv_path)</code> before rendering</td>
</tr>
</tbody></table>
<p>The NaN one cost me the most time. The symptom was completely confusing — server returned 200, JavaScript got a response, but the table was blank. Silent failure. Turned out <code>response.json()</code> was throwing a parse error because <code>NaN</code> is not valid JSON (it's <code>null</code> in JSON), and the whole preview section was quietly dying in a catch block. Classic.</p>
<hr />
<h2>What I'd Do Differently</h2>
<p><strong>1. Proper file storage</strong> Right now CSVs are saved to a local <code>uploads/</code> folder. If the server restarts, old chat sessions can't reload their charts because the files are gone. In production I'd use S3 — store the CSV path as an S3 key, not a local filesystem path.</p>
<p><strong>2. Background jobs for AI calls</strong> Right now the <code>/ask</code> endpoint blocks until the AI responds — usually 3-8 seconds. A better pattern is to return a job ID immediately, process the AI call in a background worker (Celery, or even a simple thread), and have the frontend poll or use WebSockets for the result. Feels much faster.</p>
<p><strong>3. Streaming AI responses</strong> The Groq API supports streaming responses — you can start sending tokens to the frontend as they arrive, exactly like ChatGPT does. The current setup waits for the full response before returning. Streaming would feel dramatically faster even if total time is the same.</p>
<p><strong>4. PDF charts as stored images</strong> Right now the PDF export re-generates charts from the original CSV. If the CSV is gone, charts are skipped silently. Better to store the chart image in S3 alongside the CSV, and reference it directly in the PDF.</p>
<hr />
<h2>Key Takeaways</h2>
<p><strong>Send summaries to AI, not raw data.</strong> Structured text summaries are more token-efficient, equally informative for analysis, and let you control exactly what context the model has. This is the pattern most production data AI tools use.</p>
<p><strong>Separate your AI calls.</strong> One call for the text answer, a separate call for chart type, another for column selection. Each prompt is simpler, outputs are more parseable, and failures are isolated.</p>
<p><strong>SQLAlchemy's cascades are powerful.</strong> <code>cascade='all, delete-orphan'</code> One time and your entire data hierarchy cleans up automatically. No manual delete queries across tables.</p>
<p><strong>Flask Blueprints are just an organisation.</strong> They're not especially complex — they're a way to split a growing <code>app.py</code> list into logical groups. Start using them before your app file gets too big, not after.</p>
<p><code>NaN</code> <strong>is not</strong> <code>null</code><strong>.</strong> In JSON, missing values are <code>null</code>. Python's <code>float('nan')</code> serializes to bare <code>NaN</code> which browsers can't parse. Always sanitize DataFrames before JSONifying them.</p>
<hr />
<h2>Resources</h2>
<ul>
<li><p>GitHub repo: <a href="https://github.com/shlokbam/ai-data-analyst">shlokbam/ai-data-analyst</a></p>
</li>
<li><p><a href="https://console.groq.com/docs">Groq API docs</a></p>
</li>
<li><p><a href="https://flask-login.readthedocs.io">Flask-Login documentation</a></p>
</li>
<li><p><a href="https://docs.sqlalchemy.org/en/20/orm/quickstart.html">SQLAlchemy ORM tutorial</a></p>
</li>
<li><p><a href="https://www.reportlab.com/docs/reportlab-userguide.pdf">ReportLab user guide</a></p>
</li>
</ul>
<p>If you're building something similar or hit different errors, drop them in the comments. Always curious what breaks for other people.</p>
]]></content:encoded></item><item><title><![CDATA[I Built a Full DevOps CI/CD Pipeline from Scratch — Here's Everything That Went Wrong]]></title><description><![CDATA[A honest, detailed walkthrough of building a Flask + Docker + Jenkins + Terraform + AWS project — including every error, every fix, and every "why is this not working" moment.

Before We Start — Why I]]></description><link>https://shlokbam.hashnode.dev/i-built-a-full-devops-ci-cd-pipeline-from-scratch-here-s-everything-that-went-wrong</link><guid isPermaLink="true">https://shlokbam.hashnode.dev/i-built-a-full-devops-ci-cd-pipeline-from-scratch-here-s-everything-that-went-wrong</guid><dc:creator><![CDATA[Shlok Bam]]></dc:creator><pubDate>Sat, 14 Mar 2026 09:28:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/19d8c0cc-1f8c-428c-b424-593855989845.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>A honest, detailed walkthrough of building a Flask + Docker + Jenkins + Terraform + AWS project — including every error, every fix, and every "why is this not working" moment.</em></p>
<hr />
<h2>Before We Start — Why I Built This</h2>
<p>I'm learning DevOps. And like most people learning DevOps, I was drowning in theory. I knew what Docker <em>was</em>. I could explain CI/CD in an interview. But I hadn't actually built a full pipeline from scratch.</p>
<p>So I decided to stop watching tutorials and just build something real.</p>
<p>The goal was simple — take a Flask web app, containerize it with Docker, provision cloud infrastructure with Terraform, and set up Jenkins to automatically deploy every time I push code to GitHub.</p>
<p>Simple in theory. Absolutely chaotic in practice.</p>
<p>This is the full story — every step, every error, every fix, and every "oh that's why" moment. If you're learning DevOps and want something real to build, follow along.</p>
<hr />
<h2>What I Built</h2>
<p>A simple <strong>Task Manager web app</strong> — you can add tasks, mark them done, delete them. Nothing fancy. The point wasn't the app. The point was the pipeline around it.</p>
<p>Here's what the full setup looks like:</p>
<pre><code class="language-plaintext">Your Laptop
    │
    │ git push
    ▼
GitHub Repo
    │
    │ webhook trigger
    ▼
Jenkins (running on AWS EC2)
    │
    ├── Stage 1: Clone latest code
    ├── Stage 2: Build Docker image
    ├── Stage 3: Deploy with Docker Compose
    └── Stage 4: Verify deployment
                │
                ▼
        Flask Container (port 5000)
                │
                ▼
        MySQL Container (port 3306)
                │
                ▼
        Live app at http://&lt;EC2-IP&gt;:5000
</code></pre>
<p>Every time I push code → webhook triggers Jenkins → Jenkins builds and deploys automatically → changes are live in minutes. No manual steps.</p>
<p><strong>Tech stack:</strong></p>
<table>
<thead>
<tr>
<th>What</th>
<th>Tool</th>
</tr>
</thead>
<tbody><tr>
<td>Web App</td>
<td>Python Flask</td>
</tr>
<tr>
<td>Database</td>
<td>MySQL 8.0</td>
</tr>
<tr>
<td>Containerization</td>
<td>Docker + Docker Compose</td>
</tr>
<tr>
<td>Infrastructure</td>
<td>Terraform</td>
</tr>
<tr>
<td>CI/CD</td>
<td>Jenkins</td>
</tr>
<tr>
<td>Cloud</td>
<td>AWS EC2 (Mumbai region)</td>
</tr>
<tr>
<td>Version Control</td>
<td>GitHub</td>
</tr>
</tbody></table>
<p>Let's build it step by step.</p>
<hr />
<h2>Phase 1 — The Flask App</h2>
<p>First things first — I needed an actual app to deploy. I built a simple Task Manager with Flask and MySQL.</p>
<p>The app has 5 routes:</p>
<pre><code class="language-python">@app.route("/")           # show all tasks
@app.route("/add")        # add a new task
@app.route("/toggle/&lt;id&gt;") # mark done/undone
@app.route("/delete/&lt;id&gt;") # delete a task
@app.route("/health")     # health check for Docker
</code></pre>
<p>That <code>/health</code> route matters — Docker uses it to know when the container is actually ready to accept connections. More on that later.</p>
<p>One thing I was careful about — Flask connects to MySQL using <strong>environment variables</strong>, not hardcoded credentials:</p>
<pre><code class="language-python">def get_db_connection():
    conn = mysql.connector.connect(
        host=os.environ.get("MYSQL_HOST", "localhost"),
        user=os.environ.get("MYSQL_USER", "root"),
        password=os.environ.get("MYSQL_PASSWORD", "root"),
        database=os.environ.get("MYSQL_DB", "devops")
    )
    return conn
</code></pre>
<p>These values get passed in by Docker Compose later. This is the right way to handle config — keep it out of your code.</p>
<p>The app also auto-creates the database table on startup:</p>
<pre><code class="language-python">def init_db():
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS tasks (
            id INT AUTO_INCREMENT PRIMARY KEY,
            title VARCHAR(255) NOT NULL,
            done BOOLEAN DEFAULT FALSE,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.commit()
</code></pre>
<p>No manual SQL setup needed. The table just appears on first run.</p>
<hr />
<h2>Phase 2 — Dockerizing the App</h2>
<h3>Writing the Dockerfile</h3>
<p>The Dockerfile defines how to build the Flask app into a Docker image:</p>
<pre><code class="language-dockerfile">FROM python:3.9-slim

WORKDIR /app

RUN apt-get update &amp;&amp; apt-get install -y gcc default-libmysqlclient-dev pkg-config &amp;&amp; \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]
</code></pre>
<p>Let me explain what each part actually does, because I spent time understanding this:</p>
<ul>
<li><p><code>python:3.9-slim</code> — lightweight Python base image. The full Python image is 900MB+. Slim is ~130MB. Smaller image = faster builds and pulls.</p>
</li>
<li><p><code>gcc</code> <strong>and</strong> <code>default-libmysqlclient-dev</code> — the <code>mysql-connector-python</code> package needs these to compile. Without them, <code>pip install</code> fails with a cryptic error.</p>
</li>
<li><p><code>COPY requirements.txt</code> <strong>before</strong> <code>COPY . .</code> — this is intentional. Docker caches each layer. If you copy requirements.txt first and install dependencies, Docker only reinstalls packages when requirements.txt actually changes — not every time you change your app code. Saves minutes on every build.</p>
</li>
</ul>
<h3>Writing Docker Compose</h3>
<p>One container for Flask, one for MySQL. Docker Compose manages both:</p>
<pre><code class="language-yaml">version: "3.8"

services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: "devops"
      MYSQL_ROOT_PASSWORD: "root"
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    networks:
      - two-tier
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 60s

  flask:
    build:
      context: .
    ports:
      - "5000:5000"
    environment:
      - MYSQL_HOST=mysql
      - MYSQL_USER=root
      - MYSQL_PASSWORD=root
      - MYSQL_DB=devops
    networks:
      - two-tier
    depends_on:
      mysql:
        condition: service_healthy

volumes:
  mysql-data:

networks:
  two-tier:
</code></pre>
<p>Three things here that actually matter:</p>
<p><strong>1. Docker networking</strong> — notice <code>MYSQL_HOST=mysql</code>. Flask connects to MySQL using the container name <code>mysql</code> as the hostname — not <code>localhost</code>. This is how Docker networking works. Containers on the same network can reach each other by their service name. This confused me initially until it clicked.</p>
<p><strong>2. Healthcheck + depends_on</strong> — <code>depends_on: condition: service_healthy</code> means Flask only starts after MySQL passes its healthcheck. Without this, Flask starts while MySQL is still initializing, can't connect, and crashes. The healthcheck pings MySQL every 10 seconds. Only when it gets a successful response does Flask start.</p>
<p><strong>3. Named volume</strong> — <code>mysql-data:/var/lib/mysql</code> stores MySQL data in a named volume, not inside the container. This means your data survives container restarts and even redeployments. Without this, every <code>docker compose down</code> would wipe all your data.</p>
<h3>First Problem — Port 3306 Already in Use</h3>
<p>I ran <code>docker compose up -d --build</code> and got this:</p>
<pre><code class="language-plaintext">Error response from daemon: ports are not available: exposing port TCP 
0.0.0.0:3306 -&gt; 127.0.0.1:0: listen tcp 0.0.0.0:3306: bind: address already in use
</code></pre>
<p>My Mac had MySQL installed locally and already using port 3306.</p>
<p><strong>Fix:</strong> Changed the port mapping in docker-compose.yml from <code>3306:3306</code> to <code>3307:3306</code>. This means my Mac uses port 3307 externally, but inside Docker's network containers still communicate on 3306. Flask was unaffected because Flask talks to MySQL <em>inside</em> the Docker network, not through the host port.</p>
<pre><code class="language-bash">docker compose down
docker compose up -d --build
docker ps
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/2d557290-b132-4baf-8291-bf66397825bd.png" alt="" style="display:block;margin:0 auto" />

<h3>Verifying MySQL Actually Works</h3>
<p>This is something I'd recommend everyone do — don't just trust the UI. Connect directly to MySQL and verify:</p>
<pre><code class="language-bash">docker exec -it mysql mysql -uroot -proot devops
</code></pre>
<pre><code class="language-sql">SHOW TABLES;
SELECT * FROM tasks;
</code></pre>
<p>I could see my tasks in the database. <code>done = 1</code> for completed tasks, <code>done = 0</code> for pending. The auto-increment IDs had gaps (1, 2, 4) because I'd deleted task 3 — completely normal MySQL behaviour.</p>
<p><strong>Phase 1 done. Flask app + MySQL running locally in Docker, data persisting correctly.</strong></p>
<hr />
<h2>Phase 3 — AWS Infrastructure with Terraform</h2>
<p>Now I needed to get this running on AWS. But instead of clicking through the AWS console, I used Terraform to define the infrastructure as code.</p>
<h3>What is Terraform and Why Use It?</h3>
<p>Terraform is a tool that lets you describe your cloud infrastructure in code files. Instead of manually clicking through 10 screens in AWS console to create an EC2 instance, you write a <code>.tf</code> file and run one command. Terraform makes the API calls to AWS and creates everything.</p>
<p>The benefit is repeatability. If I need to recreate my infrastructure, I just run <code>terraform apply</code> again. If someone else wants to run this project, they run the same command and get identical infrastructure. No more "it worked on my account" problems.</p>
<h3>Step 1 — Create IAM User</h3>
<p>First rule of AWS — never use root credentials for programmatic access. I created a dedicated IAM user:</p>
<ol>
<li><p>AWS Console → IAM → Users → Create User</p>
</li>
<li><p>Username: <code>terraform-user</code></p>
</li>
<li><p>Attach policy: <code>AdministratorAccess</code></p>
</li>
<li><p>Security credentials tab → Create access key → CLI use case</p>
</li>
<li><p>Download the CSV — <strong>you only see the secret key once</strong></p>
</li>
</ol>
<h3>Step 2 — Configure AWS CLI</h3>
<pre><code class="language-bash">brew install awscli
aws configure
</code></pre>
<p>Entered the access key, secret key, region (<code>ap-south-1</code> — Mumbai, closest to me in India), and output format (<code>json</code>).</p>
<p>Verified it worked:</p>
<pre><code class="language-bash">aws sts get-caller-identity
</code></pre>
<pre><code class="language-json">{
    "UserId": "AIDAVYL6B7OWOE46SJFVF",
    "Account": "395938234560",
    "Arn": "arn:aws:iam::395938236560:user/terraform-user"
}
</code></pre>
<p>This command asks AWS "who am I?" — if it returns your account details, credentials are configured correctly. If Terraform can run this command, it can create resources in your account.</p>
<h3>Step 3 — The Three Terraform Files</h3>
<p><code>variables.tf</code> — stores values that might change:</p>
<pre><code class="language-hcl">variable "aws_region" {
  default = "ap-south-1"
}

variable "instance_type" {
  default = "t2.micro"
}

variable "key_name" {
  description = "Your EC2 key pair name"
}
</code></pre>
<p><code>key_name</code> has no default — Terraform will ask for it every time you run apply. This is intentional because key pair names are personal to each AWS account.</p>
<p><code>main.tf</code> — the actual AWS resources:</p>
<pre><code class="language-hcl">provider "aws" {
  region = var.aws_region
}

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

resource "aws_security_group" "flask_sg" {
  name        = "flask-jenkins-sg"
  description = "Allow SSH, Jenkins, and Flask"

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Jenkins"
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Flask App"
    from_port   = 5000
    to_port     = 5000
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "flask_server" {
  ami                         = "ami-0f58b397bc5c1f2e8"
  instance_type               = var.instance_type
  key_name                    = var.key_name
  vpc_security_group_ids      = [aws_security_group.flask_sg.id]
  subnet_id                   = tolist(data.aws_subnets.default.ids)[0]
  associate_public_ip_address = true

  root_block_device {
    volume_size = 20
  }

  tags = {
    Name = "flask-jenkins-server"
  }
}
</code></pre>
<p>The security group is basically a firewall. Port 22 for SSH, 8080 for Jenkins, 5000 for Flask. Without opening these ports, nothing is reachable from outside the EC2.</p>
<p><code>outputs.tf</code> — prints useful info after apply:</p>
<pre><code class="language-hcl">output "ec2_public_ip" {
  value = aws_instance.flask_server.public_ip
}

output "ssh_command" {
  value = "ssh -i ~/.ssh/\({var.key_name}.pem ubuntu@\){aws_instance.flask_server.public_ip}"
}
</code></pre>
<p>This prints your EC2 IP and exact SSH command after Terraform finishes. I love this — no need to go back to AWS console to find the IP.</p>
<h3>Step 4 — Create Key Pair</h3>
<p>In AWS Console → EC2 → Key Pairs → Create:</p>
<ul>
<li><p>Name: <code>flask-key</code></p>
</li>
<li><p>Type: RSA, Format: <code>.pem</code></p>
</li>
<li><p>Download it</p>
</li>
</ul>
<p>Then on my Mac:</p>
<pre><code class="language-bash">mv ~/Downloads/flask-key.pem ~/.ssh/
chmod 400 ~/.ssh/flask-key.pem
</code></pre>
<p><code>chmod 400</code> makes the key readable only by you. SSH refuses to use keys with loose permissions — you'll get "WARNING: UNPROTECTED PRIVATE KEY FILE" and the connection gets rejected.</p>
<h3>Step 5 — Terraform Init, Plan, Apply</h3>
<pre><code class="language-bash">cd terraform
terraform init
</code></pre>
<p>This downloads the AWS provider plugin. You'll see a <code>.terraform</code> folder appear. The <code>.terraform.lock.hcl</code> file locks the exact provider version — same idea as <code>requirements.txt</code> for Python.</p>
<pre><code class="language-bash">terraform plan
</code></pre>
<p>This is a dry run. Terraform shows exactly what it will create without actually doing anything. I always run this before apply — no surprises.</p>
<pre><code class="language-bash">terraform apply
</code></pre>
<p>Type <code>flask-key</code> for the key name, then <code>yes</code> to confirm.</p>
<h3>Debugging — No Default Subnets</h3>
<p>First error I hit:</p>
<pre><code class="language-plaintext">Error: creating EC2 Instance: No subnets found for the default VPC
</code></pre>
<p>My AWS account had a default VPC but no default subnets inside it. Terraform couldn't place the EC2 anywhere.</p>
<p><strong>Fix:</strong></p>
<pre><code class="language-bash">aws ec2 create-default-subnet --availability-zone ap-south-1a
</code></pre>
<p>Re-ran <code>terraform apply</code> and it worked.</p>
<h3>Debugging — No Public IP</h3>
<p>Apply succeeded but:</p>
<pre><code class="language-plaintext">ec2_public_ip = ""
</code></pre>
<p>The EC2 was created without a public IP, so I couldn't reach it from the internet.</p>
<p><strong>Fix:</strong> Added one line to the <code>aws_instance</code> block in <code>main.tf</code>:</p>
<pre><code class="language-hcl">associate_public_ip_address = true
</code></pre>
<p>Ran <code>terraform apply</code> again. This time Terraform destroyed the old EC2 and created a new one — because public IP association can't be changed on a running instance. That's fine. That's Terraform doing the right thing.</p>
<p>This time:</p>
<pre><code class="language-plaintext">ec2_public_ip = "43.205.146.206"
ssh_command = "ssh -i ~/.ssh/flask-key.pem ubuntu@43.205.146.206"
</code></pre>
<img alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Phase 4 — Setting Up the EC2 Server</h2>
<p>SSH into the freshly created EC2:</p>
<pre><code class="language-bash">ssh -i ~/.ssh/flask-key.pem ubuntu@43.205.146.206
</code></pre>
<h3>Installing Docker</h3>
<pre><code class="language-bash">sudo apt update &amp;&amp; sudo apt upgrade -y
sudo apt install docker.io docker-compose-v2 -y
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker ubuntu
newgrp docker
</code></pre>
<p><code>systemctl enable</code> ensures Docker starts automatically on reboot. <code>usermod -aG docker ubuntu</code> adds ubuntu user to the docker group — otherwise every <code>docker</code> command needs <code>sudo</code>.</p>
<h3>Installing Jenkins</h3>
<p>Jenkins needs Java first:</p>
<pre><code class="language-bash">sudo apt install openjdk-17-jdk -y
</code></pre>
<p>Then add the Jenkins repository and install:</p>
<pre><code class="language-bash">curl -fsSL https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key | \
  gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/jenkins.gpg &gt; /dev/null

echo "deb [signed-by=/etc/apt/trusted.gpg.d/jenkins.gpg] \
  https://pkg.jenkins.io/debian-stable binary/" | \
  sudo tee /etc/apt/sources.list.d/jenkins.list &gt; /dev/null

sudo apt update --allow-insecure-repositories
sudo apt install jenkins -y --allow-unauthenticated
</code></pre>
<blockquote>
<p><strong>Honest note:</strong> The GPG key verification failed multiple times with various errors. I tried 4 different methods. Eventually I used <code>--allow-unauthenticated</code> to bypass it. For a production server I'd fix this properly — for a learning project on a temporary EC2, getting Jenkins installed was more important.</p>
</blockquote>
<p>Give Jenkins Docker permissions — critical step:</p>
<pre><code class="language-bash">sudo usermod -aG docker jenkins
sudo systemctl restart jenkins
</code></pre>
<p>If you skip this, Jenkins will fail every build with "permission denied" when it tries to run <code>docker build</code>.</p>
<h3>Adding Swap Memory — Important</h3>
<p>t2.micro has 1GB RAM. Jenkins alone uses ~300MB. MySQL needs ~400MB. Flask needs ~100MB. That's already over 800MB on a 1GB machine.</p>
<p>The first time I ran the Jenkins pipeline, the EC2 completely froze. Couldn't SSH in, couldn't open Jenkins, nothing. The system ran out of memory and died.</p>
<p>The fix — swap space:</p>
<pre><code class="language-bash">sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h
</code></pre>
<pre><code class="language-plaintext">Mem:   954Mi   887Mi   72Mi
Swap:  1.4Gi   93Mi   1.3Gi
</code></pre>
<p>Swap is disk space used as overflow RAM. Slower than real RAM but prevents the system from freezing when memory gets tight. Adding it to <code>/etc/fstab</code> makes it survive reboots.</p>
<h3>Jenkins Initial Setup</h3>
<p>Get the initial admin password:</p>
<pre><code class="language-bash">sudo cat /var/lib/jenkins/secrets/initialAdminPassword
</code></pre>
<p>Open <code>http://&lt;EC2-IP&gt;:8080</code> in browser, paste the password, click "Install suggested plugins", create an admin user.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/4def1cb4-2567-4dd1-9ce4-46fc2ed7edca.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Phase 5 — The Jenkins CI/CD Pipeline</h2>
<h3>The Jenkinsfile</h3>
<p>This file lives in your repository and defines the pipeline. Jenkins reads it from GitHub on every build:</p>
<pre><code class="language-groovy">pipeline {
    agent any

    stages {
        stage('Clone Code') {
            steps {
                git branch: 'main', url: 'https://github.com/shlokbam/flask-todo-app.git'
            }
        }

        stage('Build Docker Image') {
            steps {
                sh 'docker build -t flask-todo-app:latest .'
            }
        }

        stage('Deploy with Docker Compose') {
            steps {
                sh 'docker compose down || true'
                sh 'docker compose up -d --build'
            }
        }

        stage('Deployment Status') {
            steps {
                sh 'docker ps'
                echo 'Deployment successful! App running on port 5000.'
            }
        }
    }
}
</code></pre>
<p>4 stages, clean and simple:</p>
<ol>
<li><p><strong>Clone Code</strong> — Jenkins pulls your latest GitHub code onto the EC2</p>
</li>
<li><p><strong>Build Docker Image</strong> — builds a fresh Flask image from your Dockerfile</p>
</li>
<li><p><strong>Deploy with Docker Compose</strong> — stops old containers, starts new ones</p>
</li>
<li><p><strong>Deployment Status</strong> — runs <code>docker ps</code> to confirm everything is running, then prints success</p>
</li>
</ol>
<p>The <code>|| true</code> on <code>docker compose down</code> means "if no containers are running, don't fail" — handles the first run where there's nothing to stop.</p>
<h3>Creating the Pipeline in Jenkins</h3>
<ol>
<li><p>Dashboard → New Item → Pipeline → name it <code>flask-todo-pipeline</code></p>
</li>
<li><p>Scroll to Pipeline section</p>
</li>
<li><p>Definition: <strong>Pipeline script from SCM</strong></p>
</li>
<li><p>SCM: <strong>Git</strong></p>
</li>
<li><p>Repository URL: your GitHub repo URL</p>
</li>
<li><p>Branch: <code>*/main</code></p>
</li>
<li><p>Script Path: <code>Jenkinsfile</code></p>
</li>
<li><p>Save</p>
</li>
</ol>
<h3>The Build That Took 51 Minutes to Fail</h3>
<p>I clicked Build Now. Stage 1, 2, 3 went green. Stage 3 "Deploy with Docker Compose" started...</p>
<p>And kept going. 10 minutes. 20 minutes. 40 minutes. 51 minutes. Still running.</p>
<p>The EC2 froze again. Jenkins UI stopped responding.</p>
<p>This time it wasn't memory — it was <strong>disk space</strong>.</p>
<pre><code class="language-plaintext">Usage of /: 99.8% of 6.71GB
</code></pre>
<p>The default EC2 root volume is 8GB. Docker had downloaded the MySQL image (~600MB), the Python image, build cache, Jenkins files — and the disk was completely full. Docker couldn't finish pulling images. Jenkins couldn't write logs. Everything froze.</p>
<p><strong>Fix — free up disk first:</strong></p>
<pre><code class="language-bash">docker system prune -af
sudo rm -rf /var/lib/jenkins/workspace/flask-todo-pipeline
</code></pre>
<p><code>docker system prune -af</code> removes all unused images, containers, and build cache. Freed 629MB instantly.</p>
<p><strong>Fix — upgrade the disk via Terraform:</strong></p>
<p>Added this to <code>main.tf</code>:</p>
<pre><code class="language-hcl">root_block_device {
  volume_size = 20
}
</code></pre>
<p>Ran <code>terraform apply</code>. Terraform expanded the volume to 20GB without destroying the EC2 — just modified the block device in place.</p>
<p>But AWS expanding the volume doesn't automatically tell the OS to use it. I had to do that manually:</p>
<pre><code class="language-bash">sudo growpart /dev/xvda 1
sudo resize2fs /dev/root
df -h
</code></pre>
<pre><code class="language-plaintext">/dev/root   19G   6.2G   13G   34%
</code></pre>
<p>From 0% free to 13GB free. That's more like it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/6b32241b-d2f7-435c-88c5-a9c3ed495ff6.png" alt="" style="display:block;margin:0 auto" />

<h3>Finally — All Green</h3>
<p>Clicked Build Now again. This time with 13GB disk free and swap active:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/efb7cf35-7b02-488c-8745-d56c258820a6.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-plaintext">✅ Clone Code         — 0.87s
✅ Build Docker Image — 5 m 12s
✅ Deploy with Compose — 5m 48s
✅ Deployment Status  — 25s
</code></pre>
<p>The Deploy stage took 5 minutes because it was downloading the MySQL image for the first time. Every build after that is much faster — the image is cached.</p>
<hr />
<h2>Phase 6 — The MySQL Connection Error</h2>
<p>I opened <code>http://&lt;EC2-IP&gt;:5000</code> expecting to see my app.</p>
<p>Connection refused.</p>
<p>Checked the containers:</p>
<pre><code class="language-bash">docker ps
</code></pre>
<pre><code class="language-plaintext">flask-app   Restarting (1) 47 seconds ago
mysql       Up 4 minutes (healthy)
</code></pre>
<p>MySQL was healthy. Flask was crashing and restarting in a loop.</p>
<p>Checked Flask logs:</p>
<pre><code class="language-bash">docker logs flask-app
</code></pre>
<pre><code class="language-plaintext">mysql.connector.errors.DatabaseError: 1130 (HY000): 
Host '172.18.0.3' is not allowed to connect to this MySQL server
</code></pre>
<p>This one took me a while to understand.</p>
<p>MySQL 8.0 by default only allows the root user to connect from <code>localhost</code>. But Flask is running in a separate container with IP <code>172.18.0.3</code>. From MySQL's perspective, that's a remote host — and root isn't allowed from remote hosts.</p>
<p><strong>Fix:</strong></p>
<pre><code class="language-bash">docker exec -it mysql mysql -uroot -proot -e \
  "GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY 'root'; FLUSH PRIVILEGES;"
</code></pre>
<p><code>'root'@'%'</code> means "allow root user to connect from any host". The <code>%</code> is a wildcard.</p>
<p>Then:</p>
<pre><code class="language-bash">docker compose down
docker compose up -d --build
docker ps
</code></pre>
<p>Both containers running healthy. Opened <code>http://&lt;EC2-IP&gt;:5000</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/e3b55c80-9d50-4d4e-a1e8-3efb58ef855b.png" alt="" style="display:block;margin:0 auto" />

<p>It worked. The app was live on AWS.</p>
<hr />
<h2>Phase 7 — GitHub Webhook Automation</h2>
<p>The pipeline works. But right now I have to click "Build Now" manually every time I push code. That defeats the purpose of CI/CD.</p>
<p>Webhooks fix this.</p>
<h3>What is a Webhook?</h3>
<p>A webhook is basically GitHub saying "hey Jenkins, someone just pushed code" — it sends an HTTP POST request to Jenkins every time a push happens. Jenkins receives it and automatically starts the pipeline.</p>
<h3>Setting It Up</h3>
<p><strong>In GitHub:</strong></p>
<ol>
<li><p>Repository → Settings → Webhooks → Add webhook</p>
</li>
<li><p>Payload URL: <code>http://&lt;EC2-IP&gt;:8080/github-webhook/</code></p>
</li>
<li><p>Content type: <code>application/json</code></p>
</li>
<li><p>Events: "Just the push event"</p>
</li>
<li><p>Save</p>
</li>
</ol>
<p><strong>In Jenkins:</strong></p>
<ol>
<li><p>Pipeline → Configure</p>
</li>
<li><p>Build Triggers → check <strong>"GitHub hook trigger for GITScm polling"</strong></p>
</li>
<li><p>Save</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/0a1a0917-2bc9-4732-9a89-c5fc399610d3.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/abb6efa4-308f-48d9-9c57-a2a0c2f274c3.png" alt="" style="display:block;margin:0 auto" />

<h3>Testing It</h3>
<p>Made a small change — updated the footer text in <code>index.html</code>:</p>
<pre><code class="language-html">&lt;!-- changed from --&gt;
&lt;footer&gt;Deployed via Jenkins CI/CD Pipeline on AWS EC2&lt;/footer&gt;

&lt;!-- changed to --&gt;
&lt;footer&gt;Auto-deployed via Jenkins CI/CD | Flask + Docker + AWS&lt;/footer&gt;
</code></pre>
<p>Committed and pushed:</p>
<pre><code class="language-bash">git add .
git commit -m "update footer text"
git push origin main
</code></pre>
<p>Within seconds, Jenkins dashboard showed a new build starting automatically. No clicking. The pipeline ran all 4 stages and deployed. Refreshed the website — footer was updated.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66a2a621c1a21fb9d16d92fd/c8f4d3f8-b864-434b-b84f-8721c633bbd1.png" alt="" style="display:block;margin:0 auto" />

<p>That moment — seeing your code go from your laptop to a live server automatically — is genuinely satisfying. That's CI/CD working exactly as intended.</p>
<hr />
<h2>Everything That Went Wrong — Summary</h2>
<p>Here's every problem I hit and how I fixed it, for quick reference:</p>
<table>
<thead>
<tr>
<th>Problem</th>
<th>Cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Port 3306 already in use</td>
<td>Local MySQL using the port</td>
<td>Changed to <code>3307:3306</code> in docker-compose.yml</td>
</tr>
<tr>
<td>No subnets found</td>
<td>New AWS account without default subnets</td>
<td><code>aws ec2 create-default-subnet --availability-zone ap-south-1a</code></td>
</tr>
<tr>
<td>No public IP on EC2</td>
<td>Missing <code>associate_public_ip_address = true</code> in Terraform</td>
<td>Added the line, re-applied</td>
</tr>
<tr>
<td>EC2 froze completely</td>
<td>t2.micro ran out of 1GB RAM</td>
<td>Added 2GB swap space</td>
</tr>
<tr>
<td>Jenkins GPG key error</td>
<td>Key format incompatible with Ubuntu 24.04</td>
<td>Used <code>--allow-unauthenticated</code> flag</td>
</tr>
<tr>
<td>Jenkins startup timeout</td>
<td>Default 90s timeout too short for t2.micro</td>
<td>Increased to 300s via systemd override</td>
</tr>
<tr>
<td>Disk full, pipeline stuck</td>
<td>8GB default volume filled by Docker images</td>
<td>Upgraded to 20GB via Terraform, expanded filesystem</td>
</tr>
<tr>
<td>Flask can't connect to MySQL</td>
<td>MySQL 8.0 restricts root to localhost</td>
<td><code>GRANT ALL PRIVILEGES TO 'root'@'%'</code></td>
</tr>
</tbody></table>
<p>Every single one of these errors taught me something. The disk space issue taught me about Docker layer caching. The MySQL permissions error taught me about MySQL's default security model. The RAM issue taught me about swap memory.</p>
<p>You learn more from things breaking than from things working.</p>
<hr />
<h2>What I'd Do Differently</h2>
<p><strong>1. Use t2.medium instead of t2.micro</strong> t2.micro with 1GB RAM is genuinely painful for running Jenkins + Docker + MySQL. It works, but with swap memory and timeouts. 2GB RAM makes everything smoother.</p>
<p><strong>2. Use environment variables for secrets</strong> The MySQL password is hardcoded as "root" in docker-compose.yml. In a real project I'd use AWS Secrets Manager or at minimum a <code>.env</code> file that's never committed to GitHub.</p>
<p><strong>3. Add a</strong> <code>terraform.tfvars</code> <strong>file</strong> Instead of typing <code>flask-key</code> every time Terraform asks, I'd store it in a <code>terraform.tfvars</code> file:</p>
<pre><code class="language-hcl">key_name = "flask-key"
</code></pre>
<p><strong>4. Use</strong> <code>user_data</code> <strong>in Terraform</strong> Terraform's <code>user_data</code> lets you run a shell script when EC2 first starts — so Docker and Jenkins get installed automatically as part of <code>terraform apply</code>. No manual SSH setup needed.</p>
<hr />
<h2>Key Takeaways</h2>
<p><strong>Docker networking</strong> — containers communicate by service name, not localhost. This is one of those things that sounds obvious in theory and confuses everyone in practice.</p>
<p><strong>Infrastructure as Code</strong> — once you understand Terraform, you'll never want to click through AWS console again. The ability to <code>terraform destroy</code> and <code>terraform apply</code> and get back exactly what you had is genuinely powerful.</p>
<p><strong>CI/CD is just automation</strong> — it sounds complex but it's literally: code change → trigger → build → deploy. The magic is that each step is reliable and repeatable.</p>
<p><strong>Real projects break</strong> — every tutorial shows you the happy path. Real projects hit disk limits, memory constraints, GPG key incompatibilities, and MySQL permission errors. Debugging these is the actual job.</p>
<hr />
<h2>Resources</h2>
<ul>
<li><p>GitHub repo: <a href="https://github.com/shlokbam/flask-todo-app">github.com/shlokbam/flask-todo-app</a></p>
</li>
<li><p><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs">Terraform AWS provider docs</a></p>
</li>
<li><p><a href="https://www.jenkins.io/doc/book/pipeline/syntax/">Jenkins Pipeline syntax</a></p>
</li>
<li><p><a href="https://docs.docker.com/compose/compose-file/">Docker Compose reference</a></p>
</li>
</ul>
<hr />
<p><em>If you built this or hit different errors, share in the comments. Would love to know what broke for you.</em></p>
]]></content:encoded></item></channel></rss>