Customer feedback theme extractor on Civo
Build a secure, self-hosted pipeline that ingests customer feedback, uses a local Llama 3 GPU model to extract themes and sentiment, and visualizes insights in a React dashboard; no data leaves your cluster.
Written by
Software Engineer @ GoCardless
Written by
Software Engineer @ GoCardless
Shipping customer feedback to an external API is the default move, and also the one that gets flagged in security reviews. Your users are telling you exactly what's broken. They're just doing it in 500 different ways at once. This tutorial builds a self-contained alternative: feedback files land in Civo Object Store, a local LLM running on a Civo GPU node extracts themes, complaints, and feature requests, and a React dashboard surfaces the results. Nothing leaves your cluster.
What we’re building in this tutorial
Throughout this tutorial, we will be focused on 7 core points to build a self-contained customer feedback theme extractor:
- Upload a feedback CSV to Civo Object Store
- An inference worker running in the cluster polls for new files every 30 seconds
- It sends the feedback text to Llama 3 running locally on a Civo GPU node via Ollama
- Llama 3 returns structured JSON. Themes, complaints, feature requests, and sentiment
- The worker saves those results back to Object Store
- A FastAPI service reads the results and aggregates them across all batches
- A React dashboard fetches the summary and displays it. Accessible via a public LoadBalancer IP
No customer data leaves your infrastructure at any point.
Prerequisites
- Civo account
- Docker Desktop + Docker Hub account
- VS Code or the IDE of your choice
Project structure
Before you start, create this folder structure on your machine. You can do it in Finder or run mkdir -p in your terminal; either works. All files that we are going to create in later steps go here.
feedback-extractor/├── inference-worker/│ ├── Dockerfile│ ├── requirements.txt│ ├── prompts.py│ └── worker.py├── dashboard-api/│ ├── Dockerfile│ ├── requirements.txt│ └── main.py└── dashboard-ui/├── Dockerfile├── nginx.conf└── src/├── App.jsx└── App.css
Step 1: Install the CLI tools
Check what you already have:
civo versionkubectl version --client
Install anything missing:
brew install civo kubectl
Install boto3 for Object Store uploads:
pip3 install boto3 --break-system-packages
Authenticate the Civo CLI with your API key. Grab it from your dashboard under Profile → Security:
civo apikey save default <your-api-key>civo apikey use default
Step 2: Create the Object Store Bucket
civo objectstore create feedback-data --region NYC1
Once provisioned, get the access key:
civo objectstore show feedback-data --region NYC1
Then fetch the secret:
civo objectstore credential secret --access-key <access-key-from-above>
Export everything:
export CIVO_ACCESS_KEY="<your-access-key>"export CIVO_SECRET_KEY="<your-secret-key>"export CIVO_ENDPOINT="https://objectstore.nyc1.civo.com"
Step 3: Upload your first feedback file
In VS Code, create a file at ~/Desktop/feedback-extractor/feedback.csv and paste this in:
id,source,text,date1,app_store,"The dashboard takes forever to load on large datasets",2024-01-012,support,"Can you add dark mode? My eyes hurt after long sessions",2024-01-013,support,"Billing page crashed when I tried to update my card",2024-01-024,app_store,"Love the new API docs, very clear and well structured",2024-01-025,survey,"Please add CSV export to every table not just the main one",2024-01-036,support,"Two-factor authentication keeps logging me out randomly",2024-01-037,app_store,"The onboarding flow is confusing, couldn't find where to invite teammates",2024-01-048,support,"Your Slack integration is broken since last Tuesday",2024-01-049,survey,"Would love a mobile app, the mobile browser experience is rough",2024-01-0510,app_store,"Pricing is not competitive compared to alternatives",2024-01-0511,support,"Dashboard takes forever to load, please fix this",2024-01-0612,survey,"Dark mode would be amazing, please add it",2024-01-0613,app_store,"CSV export is missing from the reports section",2024-01-0714,support,"Got charged twice this month, billing is broken",2024-01-0715,survey,"The mobile experience is terrible, need a proper app",2024-01-0816,app_store,"Slack integration stopped working after your last update",2024-01-0817,support,"Loading times are unacceptable on our dataset size",2024-01-0918,survey,"Please add dark mode it would really help usability",2024-01-0919,support,"Onboarding needs a complete rework, very confusing",2024-01-1020,app_store,"Two-factor auth is unreliable, keeps kicking me out",2024-01-10
Upload it to the raw/ prefix:
cd ~/Desktop/feedback-extractorpython3 -c "import boto3from botocore.config import Configs3 = boto3.client('s3',endpoint_url='$CIVO_ENDPOINT',aws_access_key_id='$CIVO_ACCESS_KEY',aws_secret_access_key='$CIVO_SECRET_KEY',config=Config(request_checksum_calculation='when_required', response_checksum_validation='when_required'))with open('feedback.csv', 'rb') as f:s3.put_object(Bucket='feedback-data', Key='raw/feedback.csv', Body=f.read())print('done')"
Step 4: Create the Kubernetes cluster
We'll create the cluster using the Civo dashboard, which gives us direct access to GPU node sizes without any additional setup.
- Go to dashboard.civo.com and log in
- Click Kubernetes → New Cluster
- Name it “feedback-cluster”
- Under Node Pools, select Nvidia L40S 40GB Small (an.g1.l40s.kube.x1)
- Click Create Cluster
- Once ready, download the kubeconfig from the cluster page
Save the kubeconfig:
civo kubernetes config feedback-cluster --save --overwrite --region NYC1
Verify the node is up:
kubectl get nodes
Label it so we can pin Ollama to it later on:
kubectl label node <node-name> gpu=true
Replace <node-name> with the name from kubectl get nodes.
Step 5: Deploy Ollama on the GPU node
Put a brain on the GPU. Once we have created our new GPU node, we want to install our Llama model on it.
In VS Code, create ollama.yaml directly inside ~/Desktop/feedback-extractor/ and paste this in:
apiVersion: v1kind: Namespacemetadata:name: inference---apiVersion: apps/v1kind: Deploymentmetadata:name: ollamanamespace: inferencespec:replicas: 1selector:matchLabels:app: ollamatemplate:metadata:labels:app: ollamaspec:nodeSelector:gpu: "true"containers:- name: ollamaimage: ollama/ollama:latestports:- containerPort: 11434volumeMounts:- name: ollama-datamountPath: /root/.ollamavolumes:- name: ollama-dataemptyDir: {}---apiVersion: v1kind: Servicemetadata:name: ollamanamespace: inferencespec:selector:app: ollamaports:- port: 11434targetPort: 11434
Apply it:
kubectl apply -f ~/Desktop/feedback-extractor/ollama.yamlkubectl -n inference rollout status deployment/ollama
Pull Llama 3 onto the pod. This might take a couple of minutes:
OLLAMA_POD=$(kubectl -n inference get pods -l app=ollama -o jsonpath='{.items[0].metadata.name}')kubectl -n inference exec -it "$OLLAMA_POD" -- ollama pull llama3
Sanity check:
kubectl -n inference exec -it "$OLLAMA_POD" -- ollama run llama3 "Reply with one word: working"
Step 6: Build and deploy the inference worker
The inference worker is a Python script that runs as a pod inside the cluster in an infinite loop. It has one job: watch the Object Store for new feedback files, process them through the LLM, and save the results back. It has no HTTP endpoints, and nothing talks to it directly; it just runs quietly in the background, picking up files as they arrive and writing results when done.
Open inference-worker/ in VS Code and create the following four files.
inference-worker/requirements.txt
boto3==1.34.0httpx==0.27.0pydantic==2.6.0tenacity==8.2.3
inference-worker/prompts.py
SYSTEM_PROMPT = """You are a product analyst. Your job is to read batches of customerfeedback and extract structured insights. Always respond with valid JSON only —no markdown, no explanation, just the JSON object."""def build_extraction_prompt(feedback_texts: list[str]) -> str:joined = "\n---\n".join(feedback_texts)return f"""Analyse the following {len(feedback_texts)} customer feedback items.{joined}Return a JSON object with this exact structure:{{"themes": [{{"name": "theme name", "count": <int>, "description": "one sentence", "example_quotes": ["quote1", "quote2"]}}],"complaints": [{{"title": "complaint title", "frequency": "high|medium|low", "affected_area": "area"}}],"feature_requests": [{{"feature": "feature name", "demand_score": <1-10>, "context": "brief context"}}],"sentiment_breakdown": {{"positive": <percentage int>,"neutral": <percentage int>,"negative": <percentage int>}}}}"""
inference-worker/worker.py
import jsonimport loggingimport osimport timeimport boto3import httpxfrom tenacity import retry, stop_after_attempt, wait_exponentialfrom prompts import SYSTEM_PROMPT, build_extraction_promptlogging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")log = logging.getLogger(__name__)BUCKET = os.environ["OBJECT_STORE_BUCKET"]ENDPOINT = os.environ["OBJECT_STORE_ENDPOINT"]ACCESS_KEY = os.environ["OBJECT_STORE_ACCESS_KEY"]SECRET_KEY = os.environ["OBJECT_STORE_SECRET_KEY"]OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://ollama.inference.svc.cluster.local:11434")OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3")CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "25"))RAW_PREFIX = "raw/"PROCESSED_PREFIX = "processed/"s3 = boto3.client("s3",endpoint_url=ENDPOINT,aws_access_key_id=ACCESS_KEY,aws_secret_access_key=SECRET_KEY,)def list_unprocessed_files() -> list[str]:raw_keys = {obj["Key"]for obj in s3.list_objects_v2(Bucket=BUCKET, Prefix=RAW_PREFIX).get("Contents", [])if obj["Key"].endswith(".csv")}done_keys = {obj["Key"].replace(PROCESSED_PREFIX, RAW_PREFIX).replace(".json", ".csv")for obj in s3.list_objects_v2(Bucket=BUCKET, Prefix=PROCESSED_PREFIX).get("Contents", [])}return sorted(raw_keys - done_keys)def download_feedback(key: str) -> list[dict]:import csv, iobody = s3.get_object(Bucket=BUCKET, Key=key)["Body"].read().decode()return list(csv.DictReader(io.StringIO(body)))def upload_results(results: dict, source_key: str) -> None:out_key = source_key.replace(RAW_PREFIX, PROCESSED_PREFIX).replace(".csv", ".json")s3.put_object(Bucket=BUCKET,Key=out_key,Body=json.dumps(results, indent=2).encode(),ContentType="application/json",)log.info(f"Uploaded results to s3://{BUCKET}/{out_key}")@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))def call_ollama(prompt: str) -> dict:payload = {"model": OLLAMA_MODEL,"messages": [{"role": "system", "content": SYSTEM_PROMPT},{"role": "user", "content": prompt},],"stream": False,"format": "json",}resp = httpx.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=120)resp.raise_for_status()return json.loads(resp.json()["message"]["content"])def merge_chunk_results(chunk_results: list[dict]) -> dict:merged_themes: dict[str, dict] = {}merged_complaints: list[dict] = []merged_requests: dict[str, dict] = {}sentiment_totals = {"positive": 0, "neutral": 0, "negative": 0}for result in chunk_results:for theme in result.get("themes", []):name = theme["name"].lower()if name in merged_themes:merged_themes[name]["count"] += theme["count"]else:merged_themes[name] = {**theme}seen = {c["title"].lower() for c in merged_complaints}for complaint in result.get("complaints", []):if complaint["title"].lower() not in seen:merged_complaints.append(complaint)for fr in result.get("feature_requests", []):key = fr["feature"].lower()if key in merged_requests:merged_requests[key]["demand_score"] = (merged_requests[key]["demand_score"] + fr["demand_score"]) / 2else:merged_requests[key] = {**fr}for s in ("positive", "neutral", "negative"):sentiment_totals[s] += result.get("sentiment_breakdown", {}).get(s, 0)n = len(chunk_results)return {"themes": sorted(merged_themes.values(), key=lambda t: t["count"], reverse=True),"complaints": merged_complaints,"feature_requests": sorted(merged_requests.values(), key=lambda r: r["demand_score"], reverse=True),"sentiment_breakdown": {k: round(v / n) for k, v in sentiment_totals.items()},}def process_file(key: str) -> None:log.info(f"Processing {key}")rows = download_feedback(key)texts = [r["text"] for r in rows if r.get("text")]chunks = [texts[i : i + CHUNK_SIZE] for i in range(0, len(texts), CHUNK_SIZE)]chunk_results = []for idx, chunk in enumerate(chunks, 1):log.info(f" Chunk {idx}/{len(chunks)} ({len(chunk)} items)")chunk_results.append(call_ollama(build_extraction_prompt(chunk)))result = merge_chunk_results(chunk_results)result["source_file"] = keyresult["processed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())result["total_feedback_items"] = len(texts)upload_results(result, key)def main():log.info("Inference worker started")while True:pending = list_unprocessed_files()if not pending:log.info("No unprocessed files. Sleeping 30s...")time.sleep(30)continuefor key in pending:try:process_file(key)except Exception as e:log.error(f"Failed to process {key}: {e}")time.sleep(10)if __name__ == "__main__":main()
inference-worker/Dockerfile
FROM python:3.11-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY worker.py prompts.py ./CMD ["python", "worker.py"]
Now build and push the image.
Apple Silicon: Always build with --platform linux/amd64. The cluster runs AMD64 and will fail to pull an ARM image with a no match for platform in manifest error.
cd ~/Desktop/feedback-extractor/inference-workerdocker buildx build --platform linux/amd64 \-t <your-dockerhub-username>/feedback-worker:latest --push
Create the credentials secret:
kubectl -n inference create secret generic objectstore-creds \--from-literal=bucket="feedback-data" \--from-literal=endpoint="https://objectstore.nyc1.civo.com" \--from-literal=access_key="$CIVO_ACCESS_KEY" \--from-literal=secret_key="$CIVO_SECRET_KEY"
In VS Code, create inference-worker/worker-deployment.yaml:
apiVersion: apps/v1kind: Deploymentmetadata:name: inference-workernamespace: inferencespec:replicas: 1selector:matchLabels:app: inference-workertemplate:metadata:labels:app: inference-workerspec:containers:- name: workerimage: <your-dockerhub-username>/feedback-worker:latestenv:- name: OBJECT_STORE_BUCKETvalueFrom:secretKeyRef:name: objectstore-credskey: bucket- name: OBJECT_STORE_ENDPOINTvalueFrom:secretKeyRef:name: objectstore-credskey: endpoint- name: OBJECT_STORE_ACCESS_KEYvalueFrom:secretKeyRef:name: objectstore-credskey: access_key- name: OBJECT_STORE_SECRET_KEYvalueFrom:secretKeyRef:name: objectstore-credskey: secret_key- name: OLLAMA_URLvalue: "http://ollama.inference.svc.cluster.local:11434"
Deploy it:
kubectl apply -f ~/Desktop/feedback-extractor/inference-worker/worker-deployment.yamlkubectl -n inference rollout status deployment/inference-worker
Tail the logs to confirm it's working:
kubectl -n inference logs -f deployment/inference-worker
You should see:
INFO Inference worker startedINFO Processing raw/feedback.csvINFO Chunk 1/1 (20 items)INFO Uploaded results to s3://feedback-data/processed/feedback.jsonINFO No unprocessed files. Sleeping 30s...
Step 7: Build and deploy the dashboard API
Open dashboard-api/ in VS Code and create three files.
dashboard-api/requirements.txt
boto3==1.34.0fastapi==0.110.0uvicorn[standard]==0.29.0
dashboard-api/main.py
import jsonimport osimport boto3from fastapi import FastAPIfrom fastapi.middleware.cors import CORSMiddlewareapp = FastAPI()app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])s3 = boto3.client("s3",endpoint_url=os.environ["OBJECT_STORE_ENDPOINT"],aws_access_key_id=os.environ["OBJECT_STORE_ACCESS_KEY"],aws_secret_access_key=os.environ["OBJECT_STORE_SECRET_KEY"],)BUCKET = os.environ["OBJECT_STORE_BUCKET"]def get_all_results() -> list[dict]:objects = s3.list_objects_v2(Bucket=BUCKET, Prefix="processed/").get("Contents", [])return [json.loads(s3.get_object(Bucket=BUCKET, Key=obj["Key"])["Body"].read())for obj in objects if obj["Key"].endswith(".json")]@app.get("/api/summary")def summary():all_results = get_all_results()if not all_results:return {"message": "No processed data yet", "batches_processed": 0}theme_map, complaint_map, feature_map = {}, {}, {}sentiment_totals = {"positive": 0, "neutral": 0, "negative": 0}total_items = 0for result in all_results:total_items += result.get("total_feedback_items", 0)for theme in result.get("themes", []):theme_map[theme["name"]] = theme_map.get(theme["name"], 0) + theme["count"]for complaint in result.get("complaints", []):complaint_map.setdefault(complaint["title"], complaint)for fr in result.get("feature_requests", []):if fr["feature"] in feature_map:feature_map[fr["feature"]] = (feature_map[fr["feature"]] + fr["demand_score"]) / 2else:feature_map[fr["feature"]] = fr["demand_score"]for s in ("positive", "neutral", "negative"):sentiment_totals[s] += result.get("sentiment_breakdown", {}).get(s, 0)n = len(all_results)return {"total_feedback_items": total_items,"batches_processed": n,"themes": sorted([{"name": k, "count": v} for k, v in theme_map.items()], key=lambda x: x["count"], reverse=True),"top_complaints": sorted(list(complaint_map.values()), key=lambda c: {"high": 3, "medium": 2, "low": 1}.get(c["frequency"], 0), reverse=True)[:10],"feature_requests": sorted([{"feature": k, "demand_score": round(v, 1)} for k, v in feature_map.items()], key=lambda x: x["demand_score"], reverse=True)[:10],"sentiment": {k: round(v / n) for k, v in sentiment_totals.items()},}@app.get("/api/batches")def list_batches():return get_all_results()@app.get("/healthz")def health():return {"status": "ok"}
dashboard-api/Dockerfile
FROM python:3.11-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY main.py .CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
cd ~/Desktop/feedback-extractor/dashboard-apidocker buildx build --platform linux/amd64 \-t <your-dockerhub-username>/feedback-api:latest --push
In VS Code, create dashboard-api/api-deployment.yaml:
apiVersion: apps/v1kind: Deploymentmetadata:name: dashboard-apinamespace: inferencespec:replicas: 1selector:matchLabels:app: dashboard-apitemplate:metadata:labels:app: dashboard-apispec:containers:- name: apiimage: <your-dockerhub-username>/feedback-api:latestports:- containerPort: 8000env:- name: OBJECT_STORE_BUCKETvalueFrom:secretKeyRef:name: objectstore-credskey: bucket- name: OBJECT_STORE_ENDPOINTvalueFrom:secretKeyRef:name: objectstore-credskey: endpoint- name: OBJECT_STORE_ACCESS_KEYvalueFrom:secretKeyRef:name: objectstore-credskey: access_key- name: OBJECT_STORE_SECRET_KEYvalueFrom:secretKeyRef:name: objectstore-credskey: secret_key---apiVersion: v1kind: Servicemetadata:name: dashboard-apinamespace: inferencespec:selector:app: dashboard-apitype: LoadBalancerports:- port: 80targetPort: 8000
kubectl apply -f ~/Desktop/feedback-extractor/dashboard-api/api-deployment.yamlkubectl -n inference rollout status deployment/dashboard-apikubectl -n inference get svc dashboard-api
Wait for EXTERNAL-IP to populate, then test:
curl http://<EXTERNAL-IP>/api/summary
Step 8: Build and deploy the React dashboard
Node version: Vite 8 requires Node 20.19+ or 22+. Check with node --version. If you're below that, run nvm install 22 && nvm use 22.
cd ~/Desktop/feedback-extractor/dashboard-ui
npm create vite@latest . -- --template reactnpm installnpm install recharts
Open dashboard-ui/src/App.jsx in VS Code and replace the entire contents with:
import { useEffect, useState } from "react";import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";import "./App.css";const API_URL = import.meta.env.VITE_API_URL || "http://<your-api-external-ip>";const SENTIMENT_COLORS = { positive: "#22c55e", neutral: "#94a3b8", negative: "#ef4444" };const FREQUENCY_COLORS = { high: "#ef4444", medium: "#f97316", low: "#eab308" };function SentimentBar({ data }) {return (<div><div className="sentiment-labels">{Object.entries(data).map(([key, pct]) => (<span key={key} style={{ color: SENTIMENT_COLORS[key] }}>{key.charAt(0).toUpperCase() + key.slice(1)}: {pct}%</span>))}</div><div className="sentiment-bar">{Object.entries(data).map(([key, pct]) => (<div key={key} style={{ width: `${pct}%`, background: SENTIMENT_COLORS[key] }} />))}</div></div>);}function StatCard({ label, value }) {return (<div className="stat-card"><div className="stat-value">{value}</div><div className="stat-label">{label}</div></div>);}export default function App() {const [summary, setSummary] = useState(null);const [loading, setLoading] = useState(true);const [error, setError] = useState(null);const [lastUpdated, setLastUpdated] = useState(null);const load = async () => {try {const res = await fetch(`${API_URL}/api/summary`);if (!res.ok) throw new Error(`HTTP ${res.status}`);setSummary(await res.json());setLastUpdated(new Date());setError(null);} catch (e) {setError(e.message);} finally {setLoading(false);}};useEffect(() => {load();const t = setInterval(load, 30000);return () => clearInterval(t);}, []);if (loading) return <div className="fullscreen-center"><div className="spinner" /><p>Loading...</p></div>;if (error) return <div className="fullscreen-center"><p>⚠ {error}</p><button onClick={load}>Retry</button></div>;if (!summary || summary.message) return <div className="fullscreen-center"><p>No processed feedback yet.</p></div>;return (<div className="dashboard"><header className="dashboard-header"><div><h1>Customer Feedback Intelligence</h1><p className="header-meta">Processed on Civo GPU · Last updated {lastUpdated?.toLocaleTimeString()}</p></div><button className="refresh-btn" onClick={load}>↻ Refresh</button></header><div className="stats-row"><StatCard label="Feedback Items" value={summary.total_feedback_items.toLocaleString()} /><StatCard label="Batches" value={summary.batches_processed} /><StatCard label="Themes" value={summary.themes.length} /><StatCard label="Feature Requests" value={summary.feature_requests.length} /></div><section className="card"><h2>Sentiment</h2><SentimentBar data={summary.sentiment} /></section><section className="card"><h2>Top Themes</h2><ResponsiveContainer width="100%" height={280}><BarChart data={summary.themes.slice(0, 8)} layout="vertical" margin={{ left: 10, right: 20 }}><XAxis type="number" tick={{ fontSize: 12 }} /><YAxis type="category" dataKey="name" width={170} tick={{ fontSize: 12 }} /><Tooltip /><Bar dataKey="count" fill="#6366f1" radius={[0, 4, 4, 0]} /></BarChart></ResponsiveContainer></section><div className="two-col"><section className="card"><h2>Complaints</h2><ul className="complaint-list">{summary.top_complaints.map((c, i) => (<li key={i} className="complaint-item"><span className="badge" style={{ background: FREQUENCY_COLORS[c.frequency] }}>{c.frequency}</span><div className="complaint-body"><span className="complaint-title">{c.title}</span><span className="complaint-area">{c.affected_area}</span></div></li>))}</ul></section><section className="card"><h2>Feature Requests</h2><table className="feature-table"><thead><tr><th>Feature</th><th>Demand</th></tr></thead><tbody>{summary.feature_requests.map((fr, i) => (<tr key={i}><td>{fr.feature}</td><td><div className="demand-wrap"><div className="demand-track"><div className="demand-fill" style={{ width: `${fr.demand_score * 10}%` }} /></div><span className="demand-score">{fr.demand_score}</span></div></td></tr>))}</tbody></table></section></div></div>);}
Build the production bundle:
cd ~/Desktop/feedback-extractor/dashboard-uinpm run build
In VS Code, create dashboard-ui/Dockerfile:
FROM nginx:alpineCOPY dist /usr/share/nginx/htmlCOPY nginx.conf /etc/nginx/conf.d/default.confEXPOSE 80
Create dashboard-ui/nginx.conf:
server {listen 80;root /usr/share/nginx/html;index index.html;location / {try_files $uri $uri/ /index.html;}location /api/ {proxy_pass http://dashboard-api.inference.svc.cluster.local:8000;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;}}
docker buildx build --platform linux/amd64 \-t <your-dockerhub-username>/feedback-ui:latest --push
In VS Code, create dashboard-ui/ui-deployment.yaml:
apiVersion: apps/v1kind: Deploymentmetadata:name: dashboard-uinamespace: inferencespec:replicas: 1selector:matchLabels:app: dashboard-uitemplate:metadata:labels:app: dashboard-uispec:containers:- name: uiimage: <your-dockerhub-username>/feedback-ui:latestports:- containerPort: 80---apiVersion: v1kind: Servicemetadata:name: dashboard-uinamespace: inferencespec:selector:app: dashboard-uitype: LoadBalancerports:- port: 80targetPort: 80
kubectl apply -f ~/Desktop/feedback-extractor/dashboard-ui/ui-deployment.yamlkubectl -n inference get svc dashboard-ui
Open the EXTERNAL-IP in your browser.
Step 9: Watch it all come together
Upload a second feedback batch:
cd ~/Desktop/feedback-extractorpython3 -c "import boto3from botocore.config import Configs3 = boto3.client('s3',endpoint_url='$CIVO_ENDPOINT',aws_access_key_id='$CIVO_ACCESS_KEY',aws_secret_access_key='$CIVO_SECRET_KEY',config=Config(request_checksum_calculation='when_required', response_checksum_validation='when_required'))with open('feedback.csv', 'rb') as f:s3.put_object(Bucket='feedback-data', Key='raw/feedback2.csv', Body=f.read())print('uploaded')"
Watch the worker pick it up:
kubectl -n inference logs -f deployment/inference-worker
Within 30 seconds, it processes the file, and the dashboard updates automatically.
Front-end view
The dashboard gives you a summary of everything our LLM found across all your feedback batches. The stats at the top show total items processed and how many batches have run. Below that, the sentiment bar breaks down the emotional tone across your entire dataset. The themes chart ranks the most frequently mentioned topics by mention count, and the two columns at the bottom surface your top complaints by severity and feature requests by demand score. Everything your users are asking for, ranked and ready to act on.
Summary
What you’ve built is a private feedback intelligence platform. A system that takes raw customer feedback and automatically turns it into structured, actionable insights without any data leaving your infrastructure. Drop a CSV into the Object store and have your Llama 3 model classify the feedback into themes, rank complaints by severity, surface feature requests by demand score, and break down overall sentiment, all running locally on your own GPU node.
The same pattern scales to anything text-heavy: support tickets, app store reviews, NPS responses, sales call transcripts. Swap the model, adjust the prompt, and point it at a different bucket prefix. The GPU node is the only moving cost, scale it to zero between runs, and you're paying for inference time only.

Software Engineer @ GoCardless
Mostafa Ibrahim is a software engineer and technical writer specializing in developer-focused content for SaaS and AI platforms. He currently works as a Software Engineer at GoCardless, contributing to production systems and scalable payment infrastructure.
Alongside his engineering work, Mostafa has written more than 200 technical articles reaching over 500,000 readers. His content covers topics including Kubernetes deployments, AI infrastructure, authentication systems, and retrieval-augmented generation (RAG) architectures.
Share this article