Projects
static is stale
Building and Deploying My Interactive Resume Chatbot


Updating my Résumé I realized that reading it, no less 10s or 100s of them, can be pretty exhausting! So I build something different: a chatbot you can talk to about me, my skills and my projects!

Over the last two weeks, I worked on a personal project that combines my interests in infrastructure, automation, and conversational AI: a fully deployed chatbot running on AWS. Instead of only sending around a static PDF résumé, I wanted to create something more interactive — a chatbot that can answer questions about my background, projects, and skills.

This way, I hope to make recruiters’ and hiring managers’ lives a little easier, get more engagement, and learn what questions they really want to ask.

In this post, I’ll start with a high-level summary for non-technical readers. Then, for the engineers and tech-curious, I’ll deep dive into the architecture, IaC, deployment, and parts of the code.

Reading time: ~10 minutes.

Part I: The Why (2-3 minutes)

YAC: Yet another Chatbot

Yes, I get it - the web is already full of talkative, slightly too polite chatbots wrapping OpenAI for the millionth time. But even if a tool feels overused, it still has its place. And in my case it's fun: It turns a boring CV into something recruiters can actually interact with. And if that drives engagement, it justifies the tool, right?

As a Data Scientist/aspiring MLE it's also becoming increasingly important to know about deployment, maintenance, and CI/CD. After all, what value is a model if it just collects dust in some repo? Also, thinking about deployment early helps create better, production-ready code, and therefore has the chance to generate value faster, and longer.

In summary, this project isn’t just another chatbot — it’s a full-stack showcase:

  • Infrastructure as Code with Terraform
  • Container orchestration with Docker
  • Production readiness with HTTPS and reverse proxy
  • CI/CD automation with GitHub Actions
  • Backend development with FastAPI
  • Frontend development with Jinja2, HTML, SCSS, and JS

Sparked your curiosity? You can either:

  1. Contact me for a user ID, and try the Chatbot!
  2. Stick around for the deep dive with (and then still try the bot ;))

The How (7-8 minutes)

Architecture Overview

The system runs on an AWS EC2 t3.small instance with an Elastic IP. This keeps the app reachable even if I have to switch instances. Everything is Dockerized into four containers, keeping things modular and maintainable:

  • MariaDB — persistent storage of user data
  • nginx-proxy — routing and reverse proxy
  • letsencrypt-nginx-proxy-companion — automated HTTPS with Let’s Encrypt
  • fastapi-app — the chatbot itself, built with FastAPI

This architecture gives me a production-style environment on a single VM while keeping the deployment lightweight and flexible: I can swap out components, scale them independently (in the future), or debug services in isolation.

Infrastructure as Code with Terraform

To provision the environment, I used Terraform. The EC2 instance, networking rules, and security groups are all defined in code. This means I can:

  • Rebuild the entire setup from scratch with one command.
  • Version-control infrastructure alongside the application.
  • Avoid manual setup headaches when making changes later.

Setting up the app on AWS from my terminal then looks like this:

terraform apply \
    -var="ami_id=<amid-id>" \
    -var="aws_region=<region>" \
    -var="instance_type=<instance-type>" \
    -var="key_name=<key-name>" \
    -var="public_key_path=<path-to-public-key>" \
    -var="private_key_path=<path-to-private-key>"

Another neat thing about Terraform is the remote execution of scripts as part of the IaC workflow. The connection defined in my EC2 resource will copy a setup.sh script to the EC2 instance and execute it remotely:

resource "aws_instance" "chat_app" {
  ami           = var.ami_id != "" ? var.ami_id : data.aws_ami.ubuntu.id
  instance_type = var.instance_type
  key_name      = aws_key_pair.deployer.key_name
  vpc_security_group_ids = [aws_security_group.chat_sg.id]
  iam_instance_profile  = aws_iam_instance_profile.chat_profile.name

  # Ensure instance gets a public IP
  associate_public_ip_address = true

  tags = {
    Name = "chat-app-server"
  }

  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file(var.private_key_path)
    host        = self.public_ip
  }

  provisioner "file" {
    source      = "setup.sh"
    destination = "/home/ubuntu/setup.sh"
  }

  provisioner "remote-exec" {
    inline = [
      "chmod +x ~/setup.sh",
      "bash ~/setup.sh"
    ]
  }
}

And the script will install dependencies and spin up the app, just like we're used to:

sudo apt-get update -y
sudo apt-get install -y -q docker.io docker-compose git
git clone https://github.com/does-not-compile/portfolio-chatbot.git "$APP_DIR"
sudo docker-compose -f docker-compose.yml up -d --build
CI/CD with GitHub Actions

The repo lives on GitHub. Deployment is automated using GitHub Actions. A push to main triggers three jobs:

  1. Build and Push: the docker image of the updated code is built, commit SHA-tagged, and pushed to the GHCR.
  2. Deploy: the pushed image is deployed to the EC2 via SSH as a canary. If successful, replaces the old container.
  3. Stable Tag: Given step one and two where successful tag the latest version as stable.

This workflow keeps deployments smooth and resilient — no manual SSH into servers to fix things late at night.

The Chatbot Itself

At the heart of the project is the FastAPI backend. Of course, you can use any other web framework, I chose it for its fast, async performance, and easy Docker integration.

Authentication with JWTs

Many bots you see out there are stateless. And that is totally fine for most. But for me, I need to know who talks about what with my bot, if I am to learn what really interests my users about me. Therefore, users need to login with a user ID (which I provide on request) and I persist their interactions to the MariaDB. User sessions are tracked via JWT tokens set in browser cookies. Here's a simplified version of the login flow:

@router.post("/login")
async def login(userId: str = Form(...), db: Session = Depends(get_db)):
    user = crud.get_user(db, userId)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    # Create JWT cookie
    token = create_jwt(user.user_id)

    # Create a new chat session
    session = crud.create_session(db, user.user_id)

    # Redirect to chat page
    response = RedirectResponse(url=f"/chat/{session.session_id}", status_code=303)
    response.set_cookie(
        key="access_token",
        value=token,
        httponly=True,
        secure=True,
        max_age=60*60*24*7,  # 7 days
        samesite="lax",
    )
    return response

They are valid for a maximum of 7 days, after which the user will need to login again.

User Interaction

Using OpenAI's API, prompts are answered by a GPT Model (currently 4o-mini) and responses are streamed to the frontend for a "live" feel. The frontend is Jinja2 templating + HTML, SCSS, and JS.

The chat history is rendered server-side on load, then updated dynamically:

<div id="chat">
  {% for el in history %}
  <div class="{{ el.role }}-signature">
    {% if el.role == "assistant" %} Assistant {% else %} You {% endif %} |
    <span class="timestamp" data-utc="{{ el.created_at.isoformat() }}Z"></span>
  </div>
  <div class="message {{ el.role }}">{{ el.content }}</div>
  {% endfor %}
</div>

Prompts are handled via the OpenAI API, which is really easy to use:

from typing import Iterable, List, Dict
from openai import OpenAI
from core.config import settings


class OpenAIClient:
    def __init__(self):
        self.client = OpenAI(api_key=settings.OPENAI_API_KEY)
        self.model = settings.OPENAI_MODEL

    def stream_completion(self, messages: List[Dict[str, str]]) -> Iterable[str]:
        stream = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            stream=True,
        )
        for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                yield delta


openai_client = OpenAIClient()

From there, the responses are streamed to the frontend via the StreamingResponse of FastAPI:

return StreamingResponse(event_stream(), media_type="text/plain") # even_stream() is a generator using the OpenAIClient()
Lessons Learned
  • IaC: Writing Terraform for even a simple setup made me appreciate reproducibility. While also being really happy once it ran through...
  • Docker networking: Getting nginx-proxy and the chatbot to talk properly took some trial and error.
  • Let's Encrypt rate limits: staging areas exist for a reason.
  • CI/CD resilience: Canary rollback is a lifesaver.

If you want to try it out, the chatbot is live here (you will need to contact me first for your own unique user ID though). Want to know how it works under the hood? Check out the GitHub repo.

And if you’re a recruiter reading this: feel free to ask it about my background — it’s more fun than a PDF!

Thanks for reading — and if you’ve built something similar, I’d love to hear about it!