Automated code review agent on Civo GPUs
Drop any source file into a bucket. Walk away with a structured, AI-generated review; issues, complexity, and actionable suggestions just seconds later.
Written by
Software Engineer @ GoCardless
Written by
Software Engineer @ GoCardless
Code review is one of the most effective quality gates in software development, but in most teams, it is also one of the slowest. A 2023 LinearB study found that the average pull requests sit untouched for more than 24 hours before receiving a first response. SmartBear’s State of Code Review report reinforces the problem: reviewers are most effective when they examine fewer than 400 lines at a time, but the median PR in most teams exceeds that threshold, leading reviewers to either slow down or rush through their work. The downstream effect is significant; code review defects account for up to 65% of production bugs that make it past testing.
Manual-only review creates a predictable pattern: junior developers wait days for feedback on obvious mistakes. Reviewers, already stretched thin, skip structural analysis and focus only on surface errors. Issues such as SQL injection, hardcoded credentials, or weak cryptography slip through because nobody had time to look closely. The problem isn’t a lack of standards, it’s a lack of bandwidth.
This tutorial solves the problem. It walks through building a private, GPU-powered code-review agent that runs entirely within a Civo Kubernetes cluster. Upload any source file to a Civo Object Store bucket, and the pipeline automatically produces a structured JSON review: issues found (with severity and line references), an overall complexity rating, and a prioritized list of improvement suggestions. No external SaaS. No code leaving controlled infrastructure. The system operates as a first-pass reviewer; fast, consistent, and always available.
How the pipeline works
The system is built around a simple pattern: an Object Store bucket acts as the trigger, and two lightweight pods handle the work.
The sequence from file upload to JSON output follows these steps:
- A source file: Python, JavaScript, Go, or any plain-text code format, is uploaded to the code-submissions bucket inside Civo Object Store.
- The watcher pod polls the bucket every 10 seconds. When it detects a file without a corresponding
.review.jsonoutput, it downloads the file and forwards it to the reviewer pod. - The reviewer pod sends a structured analysis prompt to relaxAI and receives a JSON object that contains issues, a complexity classification, and suggestions.
- The watcher uploads the result to the same bucket as
<filename>.review.json. The original file and its review now sit side by side.
Three components make this work. The relaxAI Reviewer Pod is a FastAPI service running on a GPU node. It exposes a /review endpoint, handles the relaxAI call, strips markdown formatting from model output, and validates the response against a Pydantic schema. The Watcher Agent Pod is a standalone Python process that runs the polling loop. It handles file download, forwards files to the reviewer, and uploads results, keeping all orchestration logic separate from the inference logic. The Civo Object Store Bucket is the coordination layer between the two pods and the entry point for developers using the system.
Why this works well on Civo
Most cloud platforms make GPU access complicated, with quota requests, approval queues, and multi-day waits. Civo provisions GPU nodes in minutes with no approval process, so a cluster can be spun up for a batch of reviews and deleted when done. No idle costs, no bureaucracy.
Everything the pipeline needs, compute, Object Store, and networking, lives in the same regional environment and is managed from one dashboard. No cross-account policies, no external storage integrations, no IAM complexity. The watcher pod accesses the Object Store the same way it accesses any internal service: via a Kubernetes Secret containing credentials and a standard S3-compatible call.
The data boundary is the most important advantage for teams handling proprietary or regulated code. Source files travel from Object Store to the reviewer pod and back, entirely within the cluster. Nothing touches a third-party endpoint, which makes the setup defensible to security and compliance teams without any architectural changes.
And once this agent is running, the same setup bucket, watcher, and inference pod works for test generation, documentation drafting, or dependency analysis. Swap the prompt, rebuild the image, and a new tool is live.
Prerequisites
Before we dive in, make sure you have:
- A Civo account (sign up at civo.com if you haven't already)
- Basic Kubernetes knowledge (what deployments and services are)
- Docker installed locally (for building container images)
- Python 3.9+ installed
- relaxAI API access (get your API key from relaxAI)
- kubectl CLI configured and ready
Don’t worry if you’re not a Kubernetes expert; every step is explained in detail.
Project Layout
Create this folder structure locally. Keeping the reviewer and watcher in separate directories makes Dockerfile scoping straightforward and keeps each service’s dependencies isolated.
code-reviewer/├── reviewer/│ ├── app.py│ ├── requirements.txt│ └── Dockerfile├── watcher/│ ├── watcher.py│ ├── requirements.txt│ └── Dockerfile└── kubernetes/├── namespace.yaml├── secret.yaml├── reviewer-deployment.yaml└── watcher-deployment.yaml
Step 1: Provision and connect to the GPU cluster
Civo clusters with GPU nodes are provisioned in a few minutes. This step creates the cluster, downloads the kubeconfig file, and verifies that kubectl can reach the nodes.
- Log in to the Civo Dashboard and navigate to Kubernetes → Create Cluster.
- Set the name to code-reviewer-cluster, keep the network set to Default, and select a GPU-enabled instance type for the node pool. One node is sufficient for this tutorial.
- Click Create Cluster and wait for the status indicator to show Ready (typically 2–5 minutes)
- Click Download Kubeconfig once the cluster is ready.
Connect kubectl to the new cluster by setting the KUBECONFIG environment variable to the path of the downloaded file:
# macOS / Linuxexport KUBECONFIG=/path/to/downloaded/kubeconfig.yaml# Windowsset KUBECONFIG=C:\path\to\downloaded\kubeconfig.yaml
Verify that the node is visible and ready:
kubectl get nodes
Create a dedicated namespace and set it as the active context so that subsequent kubectl commands do not need a -n flag:
kubectl create namespace code-reviewerkubectl config set-context --current --namespace=code-reviewer
Step 2: Set up the Object Store bucket
The Object Store bucket serves as the shared file system between developers uploading code and the review pipeline. Civo’s Object Store is S3-compatible, so standard boto3 calls work against it without any additional configuration.
Create credentials
- In the Civo Dashboard, go to Object Stores → Credentials.
- Click Create a new Credential.
- Save the Access Key and Secret Key securely; both are needed in Step 5 when configuring the Kubernetes Secret.
Create the store and bucket
- Go to Object Stores → Create Object Store.
- Name it
code-review-storeand select the same region as the Kubernetes cluster (for example, lon1). - Click Create.
- Open the newly created store and click Create a new folder.
- Name the folder
code-submissionsand click Create folder.
The Object Store endpoint URL follows the format below. Note this value, it becomes the S3_ENDPOINT environment variable in Step 5.
https://objectstore.<region>.civo.com
Replace <region> with the actual region throughout the remaining steps.
Step 3: Build the reviewer service and watcher agent
Two services power the pipeline. The reviewer is a FastAPI application that handles AI inference. The watcher is a standalone script that handles orchestration. Separating them keeps each piece testable and independently deployable.
Reviewer service (reviewer/app.py)
The reviewer exposes a single /review endpoint. When called with a code file and a filename, it constructs a structured analysis prompt, sends it to relaxAI, cleans the model’s response, and returns a validated JSON object. The code is organized into four clearly labeled sections, making each responsibility easy to locate.
Create reviewer/app.py
Setup & configuration:
from fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModelfrom relaxai import Relaxaiimport os, jsonfrom typing import Listapp = FastAPI()RELAXAI_API_KEY = os.getenv("RELAXAI_API_KEY")client = Relaxai(api_key=RELAXAI_API_KEY) if RELAXAI_API_KEY else None
Data models:
class ReviewRequest(BaseModel):content: strfilename: strclass Issue(BaseModel):severity: str # high | medium | lowline: str # e.g. 'line 14' or 'general'description: strclass ReviewResponse(BaseModel):filename: strlanguage: strcomplexity: str # low | medium | highissues: List[Issue]suggestions: List[str]summary: str
Endpoints:
@app.get("/health")async def health():return {"status": "healthy", "relaxai_configured": client is not None}@app.post("/review", response_model=ReviewResponse)async def review(request: ReviewRequest):if not client:raise HTTPException(500, "RelaxAI API key not configured")content_preview = request.content[:8000]prompt = f"""Analyze the following code file and return a structured code review.File: {request.filename}Content:{content_preview}Respond ONLY with valid JSON in this exact format:{{"language": "detected language","complexity": "low | medium | high","issues": [{{"severity": "high|medium|low","line": "line X or general","description": "clear description"}}],"suggestions": ["actionable improvement"],"summary": "2-sentence overall assessment"}}Return ONLY the JSON. No explanation."""
relaxAI call & response parsing:
try:completion = client.chat.create_completion(messages=[{"role": "system","content": "You are an expert code reviewer. Return valid JSON only."},{"role": "user", "content": prompt}],model="Llama-4-Maverick-17B-128E",temperature=0.2,max_tokens=800)resp = completion.choices[0].message.content.strip()# Strip markdown fences if presentif resp.startswith("```json"): resp = resp[7:]if resp.startswith("```"): resp = resp[3:]if resp.endswith("```"): resp = resp[:-3]resp = resp.strip()data = json.loads(resp)data["filename"] = request.filenamereturn ReviewResponse(**data)except json.JSONDecodeError as e:raise HTTPException(500, f"Failed to parse AI response: {e}")except Exception as e:raise HTTPException(500, f"Review failed: {e}")if __name__ == "__main__":import uvicornuvicorn.run(app, host="0.0.0.0", port=8000)
Create reviewer/requirements.txt:
fastapi==0.104.1uvicorn==0.24.0relaxaipydantic==2.5.0
Create reviewer/Dockerfile:
FROM python:3.11-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app.py .EXPOSE 8000CMD ["python", "app.py"]
Watcher Agent (watcher/watcher.py)
The watcher runs a continuous polling loop. Every 10 seconds, it lists the bucket contents, identifies files that are missing a corresponding .review.json, and processes each one by downloading it, calling the reviewer, and uploading the result. Keeping this logic separate from the reviewer means each service has a single responsibility and can be debugged independently.
Create watcher/watcher.py
Setup & S3 client:
import boto3, httpx, json, time, osfrom botocore.client import ConfigS3_ENDPOINT = os.getenv("S3_ENDPOINT")S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY")S3_SECRET_KEY = os.getenv("S3_SECRET_KEY")S3_BUCKET = os.getenv("S3_BUCKET", "code-submissions")REVIEWER_URL = os.getenv("REVIEWER_URL", "http://reviewer-service:8000")POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "10"))s3 = boto3.client('s3',endpoint_url=S3_ENDPOINT,aws_access_key_id=S3_ACCESS_KEY,aws_secret_access_key=S3_SECRET_KEY,config=Config(signature_version='s3v4'))
Helper functions:
SUPPORTED = ('.py', '.js', '.ts', '.java', '.go', '.rb','.php', '.c', '.cpp', '.cs', '.rs', '.sh')def get_file_content(key):try:body = s3.get_object(Bucket=S3_BUCKET, Key=key)['Body'].read()try: return body.decode('utf-8')except: return body.decode('latin-1')except Exception as e:print(f"Error downloading {key}: {e}")return Nonedef upload_json(key, data):try:s3.put_object(Bucket=S3_BUCKET, Key=key,Body=json.dumps(data, indent=2),ContentType='application/json')print(f"✓ Uploaded {key}")except Exception as e:print(f"Error uploading {key}: {e}")def needs_review(filename, existing):if filename.endswith('.json'): return Falseif not filename.lower().endswith(SUPPORTED): return Falsereturn f"{filename}.review.json" not in existing
File processing:
def process_file(filename):print(f"Processing {filename}...")content = get_file_content(filename)if not content:print(f"✗ Could not read {filename}")returntry:with httpx.Client(timeout=120.0) as c:r = c.post(f"{REVIEWER_URL}/review",json={"content": content, "filename": filename})r.raise_for_status()review = r.json()upload_json(f"{filename}.review.json", review)print(f"✓ Done -- {len(review.get('issues', []))} issue(s) found")except httpx.TimeoutException:print(f"✗ Timeout processing {filename}")except Exception as e:print(f"✗ Failed: {e}")
Poll loop:
def watch_bucket():print("=" * 60)print("CODE REVIEWER WATCHER STARTED")print(f"Bucket: {S3_BUCKET} | Interval: {POLL_INTERVAL}s")print("=" * 60)while True:try:resp = s3.list_objects_v2(Bucket=S3_BUCKET)if 'Contents' not in resp:print("Bucket empty, waiting...")time.sleep(POLL_INTERVAL)continueexisting = {o['Key'] for o in resp['Contents']}to_review = [f for f in existing if needs_review(f, existing)]if to_review:print(f"\nFound {len(to_review)} file(s) to review")for f in to_review: process_file(f)else:print(".", end="", flush=True)except Exception as e:print(f"\n✗ Watch loop error: {e}")time.sleep(POLL_INTERVAL)if __name__ == "__main__":watch_bucket()
Create watcher/requirements.txt:
boto3==1.29.7httpx==0.25.1
Create watcher/Dockerfile:
FROM python:3.11-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY watcher.py .CMD ["python", "watcher.py"]
Step 4: Containerize both services
Both services need to be packaged as container images and pushed to a registry so Kubernetes can pull them during deployment. Docker Hub works well for this tutorial. Run the following commands from the root of the project directory:
# Log in to Docker Hubdocker login# Build and push the reviewer imagecd reviewerdocker build -t yourusername/civo-code-reviewer:latest .docker push yourusername/civo-code-reviewer:latest# Build and push the watcher imagecd ../watcherdocker build -t yourusername/civo-code-watcher:latest .docker push yourusername/civo-code-watcher:latestcd ..
Step 5: Deploy to the cluster
Four Kubernetes manifests bring the pipeline to life: a namespace, a secret, a reviewer deployment with its ClusterIP service, and a watcher deployment. They are applied in that order so that the secret exists before the pods start.
namespace.yaml
A namespace keeps the review agent’s resources isolated from anything else running on the cluster, making cleanup and access control straightforward.
apiVersion: v1kind: Namespacemetadata:name: code-reviewer
secret.yaml
A Kubernetes Secret stores sensitive values, API keys, and Object Store credentials, separately from the container image. The pods read these values at startup through environment variable references. Replace all placeholder values with the actual credentials from Steps 2 and 3.
apiVersion: v1kind: Secretmetadata:name: reviewer-secretsnamespace: code-reviewertype: OpaquestringData:S3_ENDPOINT: "https://objectstore.lon1.civo.com" # update regionS3_ACCESS_KEY: "your-civo-access-key"S3_SECRET_KEY: "your-civo-secret-key"S3_BUCKET: "code-submissions"RELAXAI_API_KEY: "your-relaxai-api-key"
reviewer-deployment.yaml
The reviewer pod runs on a GPU node and exposes port 8000 through a ClusterIP service. A ClusterIP service makes the reviewer accessible only from other pods within the cluster, intentionally preventing external access to the source code. Liveness and readiness probes hit the /health endpoint to keep Kubernetes informed of the pod’s state.
apiVersion: apps/v1kind: Deploymentmetadata:name: reviewernamespace: code-reviewerspec:replicas: 1selector:matchLabels:app: reviewertemplate:metadata:labels:app: reviewerspec:containers:- name: reviewerimage: yourusername/civo-code-reviewer:latestports:- containerPort: 8000env:- name: RELAXAI_API_KEYvalueFrom:secretKeyRef:name: reviewer-secretskey: RELAXAI_API_KEYresources:requests:memory: "2Gi"cpu: "1000m"nvidia.com/gpu: "1"limits:memory: "4Gi"cpu: "2000m"nvidia.com/gpu: "1"livenessProbe:httpGet:path: /healthport: 8000initialDelaySeconds: 30periodSeconds: 10readinessProbe:httpGet:path: /healthport: 8000initialDelaySeconds: 10periodSeconds: 5nodeSelector:node.kubernetes.io/instance-type: g4-g.4xlarge.kube.cluster---apiVersion: v1kind: Servicemetadata:name: reviewer-servicenamespace: code-reviewerspec:selector:app: reviewerports:- protocol: TCPport: 8000targetPort: 8000type: ClusterIP
watcher-deployment.yaml
The watcher pod reads all five secrets through environment variable references and sets REVIEWER_URL to the internal DNS name of the reviewer service. No GPU resources are needed here; the watcher is doing file I/O and HTTP calls, not inference.
apiVersion: apps/v1kind: Deploymentmetadata:name: watchernamespace: code-reviewerspec:replicas: 1selector:matchLabels:app: watchertemplate:metadata:labels:app: watcherspec:containers:- name: watcherimage: yourusername/civo-code-watcher:latestenv:- name: S3_ENDPOINTvalueFrom:secretKeyRef:name: reviewer-secretskey: S3_ENDPOINT- name: S3_ACCESS_KEYvalueFrom:secretKeyRef:name: reviewer-secretskey: S3_ACCESS_KEY- name: S3_SECRET_KEYvalueFrom:secretKeyRef:name: reviewer-secretskey: S3_SECRET_KEY- name: S3_BUCKETvalueFrom:secretKeyRef:name: reviewer-secretskey: S3_BUCKET- name: REVIEWER_URLvalue: "http://reviewer-service:8000"- name: POLL_INTERVALvalue: "10"resources:requests:memory: "256Mi"cpu: "100m"limits:memory: "512Mi"cpu: "500m"
Apply and verify
Apply the manifests in order. The secret must exist before the pods start, or they will fail to read environment variables at launch.
kubectl apply -f kubernetes/namespace.yamlkubectl config set-context --current --namespace=code-reviewer# Secret must be applied before the pods startkubectl apply -f kubernetes/secret.yamlkubectl apply -f kubernetes/reviewer-deployment.yamlkubectl apply -f kubernetes/watcher-deployment.yamlkubectl get pods -n code-reviewer -w
Once both pods show Running, check the watcher logs to confirm the polling loop has started:
kubectl logs -f deployment/watcher -n code-reviewer
Step 6: Test the pipeline end-to-end
With both pods running, it’s time to put the pipeline through a real test. Rather than using a minimal toy example, this step uses a publicly available Flask application that contains several well-documented vulnerability classes. This gives the reviewer enough material to generate a meaningful, production-grade analysis, the kind of output that would actually be useful on a real team.
The test file
The code below is adapted from OWASP’s publicly documented insecure application examples. It’s a short Flask app, but it contains five distinct issues spanning injection, authentication, and information disclosure. Exactly the kind of problems a first-pass reviewer should catch before a human ever opens the PR.
Save this as vulnerable_api.py:
# Source: adapted from OWASP WebGoat Python examples (public domain)from flask import Flask, request, render_template_stringimport sqlite3import subprocessimport hashlibimport osapp = Flask(__name__)SECRET_KEY = "hardcoded_secret_abc123"DB_PATH = "users.db"@app.route('/login', methods=['POST'])def login():username = request.form['username']password = request.form['password']conn = sqlite3.connect(DB_PATH)cursor = conn.cursor()query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"cursor.execute(query)user = cursor.fetchone()if user:return render_template_string(f"<h1>Welcome {username}</h1>")return "Login failed"@app.route('/ping', methods=['GET'])def ping():host = request.args.get('host')result = subprocess.check_output(f"ping -c 1 {host}", shell=True)return result@app.route('/hash', methods=['GET'])def get_hash():pwd = request.args.get('pwd')return hashlib.md5(pwd.encode()).hexdigest()@app.route('/debug', methods=['GET'])def debug():return str(os.environ)
Five issues are present in this file: a SQL injection vulnerability in the login route, a server-side template injection risk due to rendering unsanitised user input, a command injection vulnerability in the ping route, the use of MD5 for password-related hashing, and an exposed debug endpoint that leaks environment variables. The agent should surface all of them.
Upload the file
- Open the Civo Dashboard and navigate to Object Stores → code-review-store → code-submissions.
- Click Upload File, select vulnerable_api.py, and click Upload.
Watch the processing ogs
Switch to a terminal and watch the watcher logs. Within 10 seconds of the upload, the watcher should detect the file and forward it to the reviewer pod:
kubectl logs -f deployment/watcher -n code-reviewer
The review output
Refresh the Object Store bucket in the Civo Dashboard. Both files should now appear side by side: the original source file and the generated review.
Download vulnerable_api.py.review.json and open it. The full output from the agent should look similar to this:
{"filename": "vulnerable_api.py","language": "Python","complexity": "medium","issues": [{"severity": "high","line": "line 17","description": "SQL injection: user input is concatenated directly intothe query string. Use parameterized queries instead."},{"severity": "high","line": "line 21","description": "Server-side template injection: unsanitised username isrendered via render_template_string. An attacker caninject Jinja2 expressions and execute arbitrary code."},{"severity": "high","line": "line 26","description": "Command injection: user-supplied host is passed tosubprocess with shell=True. Use a list argument instead."},{"severity": "medium","line": "line 31","description": "MD5 is cryptographically broken. Replace withhashlib.sha256 for any security-sensitive hashing."},{"severity": "high","line": "line 35","description": "Debug endpoint exposes os.environ, leaking secrets,API keys and infrastructure details. Remove before deploy."}],"suggestions": ["Replace string-formatted SQL with parameterized queries: cursor.execute(query, (username, password)).","Use render_template with a proper template file instead of render_template_string.","Rewrite the ping route to accept only validated input and call subprocess with a list, not a string.","Replace hashlib.md5 with hashlib.sha256 or hashlib.sha3_256.","Remove the /debug route entirely, or restrict it to authenticated internal users only.","Move SECRET_KEY to an environment variable loaded via os.getenv()."],"summary": "This file contains four high-severity vulnerabilities that wouldallow remote code execution, data exfiltration, and authenticationbypass on any exposed deployment. None of these routes should bereachable in production without significant remediation."}
Troubleshooting
If the review JSON does not appear after 60 seconds, use the following commands to isolate the problem. The most common causes are incorrect Object Store credentials, a mismatched region in the S3 endpoint URL, or the reviewer pod still completing its startup health checks.
# Check pod statuskubectl get pods -n code-reviewer# Check the reviewer health endpoint directlykubectl port-forward deployment/reviewer 8000:8000 -n code-reviewercurl http://localhost:8000/health# Check watcher for errorskubectl logs deployment/watcher -n code-reviewer | grep -i error# Check reviewer logskubectl logs deployment/reviewer -n code-reviewer
Summary
A GPU-backed code review agent does not require enterprise infrastructure, a dedicated DevOps team, or an expensive SaaS subscription. The pattern built here, Object Store as the trigger, a watcher pod as the orchestrator, and an inference pod as the reviewer, is composable, lightweight, and entirely private.
A few observations worth carrying forward:
- The polling pattern is a low-friction alternative to webhooks and event queues. It adds a maximum 10-second delay, which is acceptable for internal tooling, and requires no additional infrastructure beyond what is already deployed.
- Separating the reviewer and watcher into independent services means each can be updated, debugged, or scaled without touching the other. Replacing the RelaxAI model or changing the prompt template requires only rebuilding the reviewer image.
- The same architecture works for adjacent tasks. Swap the review prompt for a test-generation prompt, and the system produces unit test stubs. Swap it for a documentation prompt, and it generates inline comments. The watcher, secrets, and Object Store setup stay identical.
- Because the cluster is already GPU-enabled, scaling from small files to large codebases or to batch processing requires no architectural changes — only adjustments to the replica count.
A natural next step for teams that find value in this tool is connecting the JSON output to a Civo Managed Database, building a searchable history of reviews that can surface recurring issues across a codebase over time.
Additional resources
The following links provide deeper context for the tools and concepts used in this tutorial.

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