5 Free Image Compression Tools Compared: Privacy, Speed, and Quality (2026)

The Test

I tested 5 popular free image compression tools on the same 2MB photo to compare privacy, speed, and output quality. Here are the results.

Results

Tool Privacy Batch WebP Output Size Time
Squash ? Local ? Yes ? 384KB 1.2s
Squoosh ? Local ? No ? 367KB 1.8s
TinyPNG ? Upload ? Yes ? 402KB 4.3s
Compressor.io ? Upload ? Yes ? 411KB 6.1s
Optimizilla ? Upload ? Yes ? 426KB 5.8s

Key Findings

Privacy-first tools are faster. Squash and Squoosh process images locally using the Canvas API. No network round-trip means 3-5x faster compression.

Batch mode matters. Squoosh produces the smallest files but processes images one at a time. If you have 20 product photos, that is 20 manual clicks. Squash combines batch processing with local privacy — a combination no other free tool offers.

WebP is the format to beat. Tools supporting WebP output achieved 20-30% smaller files than JPEG-only tools at equivalent quality. WebP browser support is now at 97% globally.

Upload-based tools are slower. TinyPNG and Compressor.io add 3-6 seconds of network latency per image. For batch work, this adds up quickly.

The Privacy Factor

Uploading images to a third-party server is not just a privacy concern — it is a compliance issue. If you handle client work, medical images, financial documents, or unreleased products, server-based tools are a liability.

Browser-based tools solve this completely. The image never leaves your device. There is no server to hack, no database to leak, no privacy policy to trust.

Bottom Line

  • Best overall: Squash — free, private, batch mode, multi-format
  • Best quality: Squoosh — slightly better compression but no batch mode
  • Best if you do not care about privacy: TinyPNG — established, reliable, but uploads your files

?? Try Squash: yangjiaqiang12.github.io/squash-image-compressor

?? Source: github.com/yangjiaqiang12/squash-image-compressor

? Support: ko-fi.com/squashtools

Custom behavior without custom code

Every successful SaaS product eventually meets the same question: a customer asks for something specific to them, you build it, and now you have a feature in your codebase that’s only meant to run for one tenant. A year later, you have a dozen of these. The codebase has if-statements checking tenant IDs, the test suite mocks out customer-specific paths, and the senior engineer who knows which branch belongs to which customer is the only person who can refactor anything.

There’s a better shape, and it doesn’t require giving up the per-customer customization. It does require separating, cleanly and firmly, the code that defines what behaviors are possible from the data that selects and parameterizes them. This article is about how to do that, where to store the data, and the security cliff you’ll fall off if you let the data become code.

What not to do

A handful of approaches show up over and over, and each has a fatal flaw:

  • Separate deployed instances per customer. This solves customization by forking the operational surface. Now you have N versions of the database, N sets of background jobs, N deploy pipelines, N versions of every bug fix to roll out. It works for two or three customers and collapses by ten.
  • Conditional code in the backend — if tenant_id == "acme": .... Cheap on day one, untenable by month six. Every developer has to know the customer landscape to make changes safely. Every refactor is risky in proportion to how many tenants have branches. Customer-specific logic spreads across the codebase by capillary action.
  • Code injected at build time. A configuration that produces a different binary per tenant. Has the same operational cost as separate instances, plus the added joy of debugging behavior that depends on what compile-time flag was set. Don’t.

The pattern that scales is to keep one codebase, one running cluster, one deploy pipeline — and to let per-tenant behavior live in data that the code consults. Basically, I am describing multi-tenancy.

Code defines the possibilities; data selects among them

Identify the points in your system where behavior can vary per tenant. These are extension points: the discount engine, the approval workflow, the export format, the notification rules. At each one, your code defines a small set of behaviors it knows how to perform. Per-tenant data picks which behaviors to use and supplies the parameters.

Concretely: a class hierarchy. A common shape is a CustomRule base class with a contract — say, applies?(context) and apply(context) — and a set of concrete implementations:

class CustomRule:
    def applies(self, context) -> bool: ...
    def apply(self, context) -> None: ...

class PercentageDiscountRule(CustomRule):
    def __init__(self, percent, min_order):
        self.percent = percent
        self.min_order = min_order

    def applies(self, context):
        return context.order_total >= self.min_order

    def apply(self, context):
        context.discount += context.order_total * (self.percent / 100)

class FirstPurchaseDiscountRule(CustomRule):
    def __init__(self, amount):
        self.amount = amount

    def applies(self, context):
        return context.customer.order_count == 0

    def apply(self, context):
        context.discount += self.amount

A tenant’s configuration is then a small declarative description — which rules they have, with what parameters:

{
  "discount_rules": [
    {"type": "percentage", "percent": 10, "min_order": 100},
    {"type": "first_purchase", "amount": 5}
  ]
}

At runtime, you load the tenant’s config, hydrate it into instances of the right rule classes, and run them. The code knows how to perform every behavior; the data says which behaviors to apply, in what order, with what parameters. To add a new kind of rule, you add a new class. To add a new tenant configuration, you change data — no deploy, no migration, no engineering.

Notice that the apply methods mutate the incoming value. If you prefer to not do so, just return that result and apply it when called. A reasonable name for this operation is result. This is really up to your preference in terms of using mutable vs immutable data. In the context of a web app, you usually do want mutability (for example, encoding and decoding a value from the database to a particular meaning for a tenant). If there is more complexity, you can put it behind a port to unit test it separately.

The shape generalizes: any extension point in your system can have its own base class, its own family of implementations, and its own data schema describing how it’s configured per tenant.

Where the data lives

The configuration has to be persisted somewhere. The options aren’t equivalent:

  • In-memory cache. Tempting because it’s fast, but caches get invalidated, evicted, and reset on deploy. If the cache is the source of truth, you’ve lost the data the moment something restarts. Caches belong in front of the source of truth, not in place of it.
  • Files on disk. Workable for very small, very stable configurations, but file I/O is slow at scale, file deployment is operational overhead, and “edit a file and redeploy” doesn’t fit the case where customer success needs to toggle something for a tenant at 4pm on a Friday.
  • Static configuration baked into the app. Fine for values that genuinely never change between deploys. But if the values are tenant-specific, you’re back to the “code per customer” problem.
  • A database. If you’re already running one — and you almost certainly are — this is the clear winner. Reads are fast (especially with a thin cache in front), updates are transactional, the data sits next to the tenant records it’s associated with, and you get backups, replication, and access control for free.

Use the database you already have. Don’t introduce a new piece of infrastructure for this.

A note on schema

Whichever shape you pick, the configuration has to be retrievable by tenant. That means a tenant_id foreign key, typically a dedicated tenant_configurations table with tenant_id referencing tenants, indexed for fast lookup. The runtime question is always the same: “given the tenant for this request, what’s their configuration?” Get that relationship in place first; everything else flows from being able to find the right rules for the right tenant.

If you’re using a relational database, the principled approach beyond that is to model the configuration with normalized tables — a tenant_discount_rules table with tenant_id, typed columns for rule type, percent, min_order, and so on, or a polymorphic schema with a separate table per rule type. This is fine, and you may end up there. But I’d push back on starting there.

For an initial proof of concept, a single table is enough:

CREATE TABLE tenant_configurations (
  tenant_id   BIGINT PRIMARY KEY REFERENCES tenants(id),
  config      JSONB  NOT NULL DEFAULT '{}'::jsonb,
  updated_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

One row per tenant, the primary key handles the lookup index, no migrations needed when you add a new kind of rule. You fetch the row by tenant_id, parse the config JSON, hydrate it into your rule classes, run them. When the configuration stabilizes, when querying into the configuration becomes important, or when validation needs to live at the database level, that’s the moment to normalize. Until then, JSON in a column is the shortest path from idea to working code, and you can refactor toward structure once you know what the structure should be.

The security cliff

There is one thing you must not do, no matter how convenient it looks: do not store executable code in the configuration, and do not let configuration values be interpreted and run.

That means no eval, no exec, no embedded JavaScript or Python or Ruby expressions, no SQL fragments concatenated into queries, no template engines that allow arbitrary function calls. It is tempting (really tempting) to support a configuration that looks like:

{
  "discount_amount": "order.total * 0.1 if customer.tier == 'gold' else 0"
}

…and eval that string at runtime. Do not. The moment you do, anyone who can write to that configuration row can execute arbitrary code on your servers, with the privileges of your application. That’s not a feature; that’s a remote code execution vulnerability you built on purpose. It doesn’t matter that the configuration is “only” editable by admins, or “only” through your UI — the surface area expands the moment another bug exposes that table, the moment a credential leaks, the moment an internal account is phished. The configuration becomes the attacker’s payload delivery mechanism, and you handed them the loaded gun.

The correct discipline is strict: configuration is data. It selects between behaviors the code already knows how to perform and supplies typed parameters to them. It never describes a new behavior. If a customer needs a behavior the code doesn’t have, the answer is to add a new rule class, not to let them write logic into a JSON blob.

This is also what makes the system safe to expose to customer-success people, support engineers, and eventually self-service customers. The blast radius of a misconfigured rule is “the rule doesn’t apply” or “the rule applies wrong”. Never “the server runs whatever I told it to.”

The shape, summarized

  • Identify per-tenant extension points and write a small base class for each.
  • Implement the concrete behaviors as subclasses of that base.
  • Store tenant configurations as data; start with a JSON column on the tenant record, normalize later if it earns it.
  • Hydrate the data into classes at runtime; let the classes do the work.
  • Never, ever let the data become code.

The principle underneath all of this is that code is the menu (the list of things your system is capable of doing) and data is the order. Customers can pick from the menu, in any combination, with any parameters. They cannot rewrite the menu. The chef writes the menu. That’s how you keep the kitchen safe.

How I Built FreeLabTools Using Only Claude and Gemini (And Why It Changes Everything)

As a solo developer, the biggest bottleneck isn’t usually the ideas—it’s the time required to execution. Recently, I wanted to launch a suite of free web tools for developers and creators, but doing it all from scratch would have taken weeks.

Instead, I decided to run an experiment: building the entire platform using a “tag-team” of Large Language Models (Claude and Gemini).

The result? FreeLabTools.com is now live, fully functional, and was built in a fraction of the time. Here is exactly how I did it.

The Strategy: Playing to Each AI’s Strengths

I quickly realized that treating AI models as generalists is a mistake. To build FreeLabTools efficiently, I assigned specific roles to each LLM based on their core strengths:

1. Claude: The Architect & Lead Coder

I used Claude (specifically Claude 3.5 Sonnet) as my primary software engineer.

  • What it did: Generated the clean, modular JavaScript logic for the tools, handled complex algorithms, and structured the UI using modern CSS/Tailwind.
  • Why it shined: Claude’s ability to maintain context over long conversations and write production-ready code with minimal bugs is unmatched. It understood the “edge cases” of client-side web tools perfectly.

2. Gemini: The Researcher, Optimizers & Copywriter

While Claude was busy coding, I used Gemini to handle the broader scope of the project.

  • What it did: Optimized the code for speed, generated SEO-friendly meta descriptions, structured the JSON-LD schema for Google, and helped brainstorm user-friendly UI copy.
  • Why it shined: Gemini’s integration with up-to-date web standards and its fast processing made it the perfect tool for refining, auditing, and preparing the site for launch.

The Workflow: How They Worked Together

The synergy was surprisingly smooth. I would ask Claude to generate a specific tool (for example, a robust code formatter or a secure password generator). Once the tool was functional, I would feed that code into Gemini with the prompt: “Review this code for performance bottlenecks and suggest SEO metadata for the tool page.”

Gemini would often spot tiny optimizations or suggest better accessibility (ARIA) attributes, which I would then feed back to Claude to implement. It felt like managing a highly cooperative two-person dev team.

Key Takeaways for Solo Devs

If you are planning to build your own SaaS or utility site like FreeLabTools.com, here is my advice:

  1. Be specific with prompts: Don’t just say “build a tool.” Define the inputs, expected outputs, and constraints.
  2. Double-check the math/logic: Even though both AIs are incredibly smart, human oversight is still required to test the final output.
  3. Automate the boring stuff: Let AI handle the boilerplate code so you can focus on user experience and deployment.

What’s Next?

Building this project proved to me that the barrier to entry for launching web platforms has completely collapsed.

I’d love for you to check out the final result at FreeLabTools.com and let me know what you think. If you have any questions about the specific prompts I used to pair Claude and Gemini, drop a comment below!

Have you tried building a full project using multiple AI models? What was your experience?

LLM Evaluation in CI: Stop Manual Testing Before It Costs You

LLM Evaluation in CI: Stop Manual Testing Before It Costs You

You ship a prompt change to production. Two hours later, a customer complains your LLM is returning hallucinated data. You rollback. You lost an hour of revenue and some user trust.

This happens because you tested the happy path, not the edge cases. LLM systems are probabilistic — the same input doesn’t always produce the same output quality.

The enterprise solution is Braintrust ($249/mo), LangSmith ($99/mo), or Arize. If you’re indie, bootstrapped, or pre-PMF, those budgets simply don’t exist.

The Core Idea: Eval-as-Code

Instead of vibes-based testing, you define quality as a rubric with concrete attributes:

  • Correctness (0–10): Is the answer factually right?
  • Conciseness (0–10): Does it avoid unnecessary padding?
  • Hallucination risk (0–10): Does it cite things it can’t know?
  • Tone (0–10): Does it match expected register?
  • Usefulness (0–10): Would a real user find this helpful?

A cheap judge model (GPT-4o-mini at ~$0.0001/call) scores each output against your rubric. You run 50 test cases per eval. Total cost: about £0.20 per full run.

Building This in GitHub Actions

Here’s the minimal structure:

name: LLM Eval
on: [pull_request]

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run evals
        run: python run_evals.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      - name: Check threshold
        run: python check_threshold.py --min-score 7.5

The run_evals.py script:

  1. Loads your golden dataset (JSON file of input/expected-output pairs)
  2. Runs your LLM system on each input
  3. Sends (input, expected, actual) to GPT-4o-mini with your rubric
  4. Aggregates scores by attribute
  5. Writes results to eval_results.json

If aggregate score drops below your threshold, check_threshold.py exits with code 1 — the PR fails.

A Real Example From Production

I changed a classification system prompt to improve response formatting. The change looked solid in manual testing on 5 examples. But I accidentally dropped a critical piece of context the model needed for correct classification.

Without evals: ships to users. Angry support tickets. Rollback. Lost trust.

With evals: CI caught it in 4 minutes. PR fails. I fix the prompt. Evals pass. Ship confidently.

Golden Datasets: The Hard Part

The hardest part is building your test cases. The key insight: start with failures, not successes.

Every time your LLM system makes a mistake:

  1. Save the input
  2. Write down what the correct output should have been
  3. Add it to your golden dataset

After 2–3 weeks of normal usage, you’ll have 30–50 meaningful test cases that represent real failure modes — far more valuable than synthetic test cases you invented upfront.

Multi-Model Comparison

Before committing to an expensive model, run your eval suite across providers:

models = ["gpt-4o-mini", "gpt-4o", "claude-3-5-haiku", "gemini-flash-1.5"]
results = {}
for model in models:
    results[model] = run_eval_suite(model, golden_dataset)

# Sort by (score / cost_per_1k_tokens) to find optimal tradeoff

This stops you from paying for GPT-4o when Claude Haiku scores 92% as well at 20% of the cost.

Cost Optimization

  • Batch your calls: OpenAI batch API gives 50% discount on async evals
  • Cache responses: Hash (model + prompt + input) → cache hit avoids re-scoring
  • Coarse-to-fine: Use a 2-stage system — cheap model filters obvious passes, expensive model only sees borderline cases
  • Weekly CI only: Run full suite on PRs to main, not every commit

A well-optimized setup runs 100 eval cases for under £0.10.

What I’ve Packaged Up

I’ve turned this into a complete ready-to-use system in The Indie Hacker’s LLM Eval Playbook:

  • 6 golden dataset templates for common LLM tasks (classification, summarization, retrieval, generation, code review, reasoning)
  • Complete rubric scoring system in Python (copy-paste ready)
  • Multi-model comparison script with cost-efficiency ranking
  • GitHub Actions workflow — drop it in your repo and it works
  • Cost optimization guide with benchmarks

£29 one-time. One avoided production incident pays for it 10× over.

If you have questions about implementing eval-as-code for your specific use case, drop them in the comments — happy to help.

Jenkins as a Code, or how I stopped clicking around in the UI

A three-column architecture diagram: git repo on the left, Jenkins controller and operator in the middle, ephemeral Linux pods + cloud VMs + macOS VMs on the right.

I’ve been running Jenkins in one form or another for years now. Different companies, different sizes of teams, but somehow the same story keeps repeating itself, and at some point I just couldn’t take it anymore. So I decided to write down what I went through, what I learned, and where this journey took me. This is Part 1 of what I’m calling My CI/CD Odyssey — a series where I want to share the ideas, the mistakes, and the things that actually worked.

Future chapters will go deeper into the painful stuff — building macOS workers without losing your mind, using spot instances as GitHub Actions runners to cut costs, and a few other rabbit holes I went into. But before we get there, let’s start at the beginning, because the beginning is where most of the pain lives.

The “before” picture, and why it hurts

If you’ve worked with Jenkins for any reasonable amount of time, you probably know this scene: someone opens the Jenkins UI, clicks “New Item”, picks a freestyle or pipeline job, fills in twenty-something fields, scrolls past a wall of plugin options, and clicks Save. Then a month later somebody has to figure out why a job behaves differently in dev than in prod, and the answer is “because Arthur clicked a different checkbox in February and nobody remembers”.

That was basically my world for a long time. We had multi-tier environments — dev, stage, sometimes more — and on top of that, sometimes more than one Jenkins instance per tier. Each one was configured by hand. Plugins installed by hand. Pipelines copy-pasted from one Jenkins to another and edited by hand. Credentials added by hand. Workers attached by hand. Then one day you wake up and realize:

  • Nobody remembers what plugins are installed where.
  • The “stage” Jenkins doesn’t match production anymore, and you only notice when a pipeline breaks in prod.
  • A plugin update on Friday afternoon kills a build, and rolling it back means a human clicking buttons under stress.
  • A new team member joins and you spend three days explaining tribal knowledge that should really live in a repo.

That last point is what really got me. Tribal knowledge is fine when there are two of you. It stops being fine very quickly.

The idea: treat Jenkins like any other piece of code

So I started doing some research, and the direction was pretty obvious in hindsight: if Jenkins is a piece of infrastructure, and we treat infrastructure as code everywhere else (Terraform for cloud, Helm for Kubernetes, Ansible for hosts), then Jenkins itself shouldn’t be the special snowflake we manage by hand. The whole controller, all the jobs, all the credentials wiring, the workers — everything should come out of a git repo. End to end.

The goal I wrote down for myself was something like this:

I want a Jenkins instance where I can throw away the whole VM, the whole cluster, the whole config, run a pipeline, and ten minutes later have an identical Jenkins back. And I want dev to be code-to-code identical to prod, so when I test a plugin upgrade or a pipeline change in dev, I actually know it will behave the same in prod.

If you’ve ever burned yourself on a “but it worked in stage” deploy, you know exactly why that sentence matters.

The building blocks

Once I started designing this, the picture broke down into a few moving pieces. None of these are revolutionary on their own — what matters is how they fit together.

A three-column architecture diagram: git repo on the left, Jenkins controller and operator in the middle, ephemeral Linux pods + cloud VMs + macOS VMs on the right.

1. JCasC — Jenkins Configuration as Code

This is the foundation. JCasC is a Jenkins plugin that lets you define the entire controller config in YAML. System settings, security realm, authorization strategy, clouds, credentials wiring, tools, global libraries — all of it. The controller reads the YAML on boot and configures itself.

The moment I plugged JCasC in and could rebuild a controller from a YAML file, I knew I wasn’t going back. No more “what’s installed where”. Whatever is in the YAML is the truth. If it’s not in the YAML, it doesn’t exist.

A minimal taste of what that looks like:

jenkins:
  systemMessage: "Managed by JCasC — do not edit in the UI"
  numExecutors: 0
  mode: EXCLUSIVE
  securityRealm:
    github:
      clientID: ${GITHUB_CLIENT_ID}
      clientSecret: ${GITHUB_CLIENT_SECRET}
  clouds:
    - kubernetes:
        name: "eks"
        namespace: "jenkins"
        jenkinsUrl: "http://jenkins.jenkins.svc.cluster.local:8080"
unclassified:
  globalLibraries:
    libraries:
      - name: "ci-libs"
        defaultVersion: "main"
        retriever:
          modernSCM:
            scm:
              git:
                remote: "https://github.com/<org>/ci-libs.git"

Fifteen lines, and the whole controller knows who it is.

2. Job DSL — jobs from a git repo

JCasC handles the controller, but it doesn’t really handle jobs. For that I leaned on the Job DSL plugin. Jobs are defined in Groovy files in a git repo, and a small “seeder” job in Jenkins polls the repo, picks up all the DSL files, and recreates jobs from them. If a job is removed from git, it disappears from Jenkins. If a parameter changes in git, it changes in Jenkins on the next seed run.

This means the Jenkins UI becomes basically read-only from a configuration point of view. Nobody edits a job in the UI anymore — if you do, the seeder will overwrite you on the next run. That’s a feature, not a bug.

Look here for declarative API

3. Helm + Kubernetes for the controller

I run the Jenkins controller in Kubernetes. Helm chart for the deploy, persistent volume for the home dir, a sidecar that injects JCasC config from a ConfigMap. Upgrading Jenkins is just bumping a chart version. Rolling back is rolling back a chart version. Plugin lists are values in a Helm values.yaml file, version-pinned, and reviewed in a pull request like any other change.

This is honestly the part that made plugin upgrades stop being scary. They go through a PR. They get tested in dev first. They get the same review as application code.

Side note: if you’d rather not deal with Helm at all, the community also maintains a Jenkins Kubernetes Operator that takes a CRD-first approach. I went with Helm for the simpler upgrade story, but the operator is a perfectly reasonable alternative if you’re already heavy into the operator pattern.

4. Packer for worker images

The next big piece is the workers — the actual machines that run your builds. Here I went all-in on Packer. Every worker image is baked from a Packer template that lives in git: base OS, language runtimes, SDKs, build tools, everything pre-installed. The image gets a version. The version gets pinned in the worker config.

This was the moment that builds started to feel reproducible. Before Packer, every worker was a slightly different snowflake, hand-installed and slowly drifting. After Packer, every worker that boots from image v1.2.3 is byte-for-byte the same as every other worker booted from image v1.2.3. If a dependency upgrade breaks something, you know exactly which image introduced it, and you can pin back to the previous one in a one-line PR.

5. Ephemeral workers — born, used, destroyed

This is the part that connects everything, and honestly the part I’m proudest of. Workers in this setup are ephemeral. Not “long-lived agents we reboot once a week” — actually ephemeral. A pipeline asks Jenkins for a worker, dedicated job spins one up from a known Packer image, the worker runs the build, the worker dies. Always. Every build gets a virgin environment.

The “something” depends on the platform, but the pattern is identical across all of them:

  • Linux builds — the Jenkins Kubernetes plugin schedules a pod in the EKS cluster from a container image we baked. Build finishes, pod is deleted. Lifecycle is seconds to minutes.
  • AWS EC2 / Azure VMs (Linux and Windows) — Dedicated job run terraform to provision and de-provision instances from packer templates.
  • macOS VMs — same idea, but the underlying virtualization is its own world. We spin up a fresh macOS VM from a Packer-baked image on each build (via Tart on Apple Silicon hosts, or vSphere for older fleets, or Orchard for pooled remote Macs), the build runs, and the VM is torn down at the end. macOS is messier and deserves its own post — that’s Part 2 — but the contract is the same: born for one build, destroyed after.

The point is: every build starts from byte-identical state. Not “mostly the same”. Not “the same modulo ~/.cache“. Identical. If v1.2.3 of an image is what’s running, then every build on that image starts from the exact same filesystem snapshot the Packer pipeline produced. There’s no human in between leaving footprints.

That kills a whole category of bugs. No more “leftover state on the agent”. No more “this worker has a weird ~/.cache somebody never cleaned up”. No more “the disk filled up because of build artifacts from three weeks ago”. No more “this only fails on Friday because the agent’s been up since Monday and something is leaking”. The worker simply doesn’t live long enough to accumulate any of that.

It also makes “build is non-reproducible” investigations a lot shorter. If two builds against the same commit produce different artifacts, the cause is almost never the worker — because the worker is brand new in both cases. That narrows the search dramatically.

And it turns out to be a beautiful security property too: secrets that get pulled onto a worker disappear with it. There’s no long-lived agent holding old tokens. If a credential leaks into a build environment, its blast radius is measured in minutes, not weeks.

One build, one worker — three platforms, same contract

6. Terraform / Terragrunt for everything else

All the things that aren’t Jenkins itself — VPCs, IAM, secret stores, the EKS cluster, image galleries — live in Terraform, organized with Terragrunt so the same modules get reused across dev and prod with different inputs. Same code, different variables. That’s how I get dev to be code-to-code identical to prod.

If you ever want to test how production will behave, just run the same Terraform with ENV=stage instead of ENV=prod. Same modules, same versions, just a different namespace. No surprises.

How it all clicks together

The flow ends up looking like this:

  1. Somebody opens a pull request — could be a new job, a plugin bump, a JCasC tweak, a new Packer image.
  2. CI runs validation: YAML lint, Groovy compile checks, Terraform plan, Packer build for changed images.
  3. PR gets reviewed and merged.
  4. On merge, GitHub Actions applies infra changes via Terraform, and the Jenkins seeder picks up new DSL files on its next poll.
  5. Next build that needs a worker pulls the new image. No human in the loop.

That’s the loop. That’s the whole point. The Jenkins UI becomes a window into what the repo says should be running, not the source of truth.

What this fixed for me

Here’s what I noticed had actually changed:

Before / After — Jenkins as a Code

  • No more “works on stage, breaks on prod”. Because the two are literally the same code with different inputs. If it works on stage, it works on prod, modulo data differences.
  • Plugin upgrades stopped being scary. They go through a PR. They get tried on dev. They roll back with git revert.
  • Onboarding got faster. New engineers read the repo. They don’t have to be told secrets or shown a Jenkins UI tour.
  • Disaster recovery got real. I can lose the controller VM, the EKS cluster, even the entire account, and as long as I have the repo I can rebuild.
  • Audit trail came for free. Every change to any pipeline is a git commit, with an author, a timestamp, and a PR description. No more “who changed this and when”.

What I’m still figuring out

I don’t want to make this sound like a finished story, because it’s not. A few things still keep me up at night:

  • macOS workers are their own special kind of hell. You can’t just spin up a Mac VM in AWS the same way you spin up Linux. There’s a whole ecosystem of hypervisors, licensing rules, and hardware constraints to deal with. This deserves its own post — and it’s getting one. Part 2 will be all about macOS workers: Tart, virtualization on Apple Silicon, the trade-offs between self-hosted and cloud-mac providers, and how to make signing and notarization not feel like a horror movie.

  • GitHub actions Cost at scale. There is easy way to run spot instances as GitHub Actions runners to offload certain workloads cheaply, save money, and that’s its own rabbit hole — different trade-offs, different failure modes, different cost curves. Part 3 will cover spot-based GitHub Actions runners end to end.

Closing thought

If there’s one thing I’d say to anyone reading this who’s still managing Jenkins by clicking buttons, it’s this: you’re not lazy for doing it, you’re just paying the cost in places that don’t show up on a dashboard. The cost shows up when someone leaves the team, when a plugin update breaks a build at 2am, when a customer-facing deploy fails because stage lied to you. Jenkins as a Code doesn’t make those costs disappear, but it makes them visible and reviewable. And that, honestly, has been worth all the work.

Appendix — tools and plugins I leaned on

For anyone who wants to skip straight to the implementations, here’s the short list of what’s actually wired up in this setup:

Jenkins plugins

Deployment

Image building

Infrastructure

  • Terraform — everything outside Jenkins (VPCs, IAM, secrets, EKS, image galleries).
  • Terragrunt — keeps the same modules DRY across dev / stage / prod.
  • Kubernetes / Amazon EKS — where the Jenkins controller lives.
  • Helm — package manager for the Kubernetes side.
  • GitHub Actions — applies Terraform on merge.

Coming up in later parts

  • Tart — macOS VMs on Apple Silicon (Part 2).
  • Orchard — Tart cluster orchestration for macOS fleets (Part 2).

This is Part 1 of My CI/CD Odyssey. If you want to be pinged when Part 2 drops, follow me here on dev.to. And if you’re doing JaaC differently — I’d love to hear about it in the comments.

I Built a Debugger for LLM Agents — Here’s Why “Observability” Wasn’t Enough

Every time I changed a prompt, I was running a hypothesis test.

But I had no debugger. No way to pause execution. No structural comparison between “before” and “after.” Just two terminal windows and a vague feeling that maybe it was better now.

I built agent-lens to fix this.

The Problem with “Observability”

Langfuse, LangSmith, Phoenix — these are great tools. They show you what happened. Traces, spans, token counts.

But none of them answer the question I actually had: did this change make it better?

That requires something different:

  • A way to compare two runs structurally
  • A record of why you made the change (the hypothesis)
  • A verdict — not just “here are the numbers,” but “this was an improvement”

What agent-lens Does Differently

1. Pause a live agent mid-run

import agent_lens
from openai import OpenAI

agent_lens.install()          # auto-patches OpenAI + Anthropic
agent_lens.dashboard.start()  # localhost:7878

client = OpenAI()

@agent_lens.trace
def my_agent(query: str) -> str:
    return client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": query}]
    ).choices[0].message.content

Open the dashboard, click Pause. The agent blocks at the next LLM call.

2. State a hypothesis before you change anything

POST /runs/{run_id}/fork
{
  "span_id": "abc123",
  "edited_messages": [{"role": "system", "content": "Be concise."}],
  "notes": "Hypothesis: shorter system prompt reduces hallucination",
  "expected_output": "concise"
}

The note travels with the run forever. Future you can read your reasoning.

3. GET /diff — one call, one verdict

GET /runs/{run_a}/diff/{run_b}
{
  "metrics_delta": {
    "latency_ms":   {"a": 1847, "b": 820,  "pct_change": -55.6},
    "total_tokens": {"a": 453,  "b": 87,   "pct_change": -80.8},
    "cost_usd":     {"a": 0.0045, "b": 0.00087, "pct_change": -80.7}
  },
  "assertion_result": {
    "expected_output": "concise",
    "passed_in_a": false,
    "passed_in_b": true,
    "verdict": "improved"
  }
}

Hypothesis confirmed. With numbers.

The Full Flow

[Agent running] → Pause → agent blocks at next LLM call
                              ↓
                    [Edit messages in dashboard]
                              ↓
                    Fork → new run diverges
                              ↓
                    Resume → original continues
                              ↓
              [Two runs. GET /diff. Get verdict.]

No restarts. No re-running preceding steps.

Zero Infrastructure

Everything runs locally. SQLite at ~/.agent-lens/runs.db. No Docker. No cloud. No API keys needed to start exploring:

pip install agentlens-tracer
python examples/07_demo_mock.py  # runs a full demo with no API key

Works with LangChain and LlamaIndex Too

from agent_lens.integrations.langchain import AgentLensCallbackHandler
from agent_lens.integrations.llamaindex import AgentLensLlamaIndexHandler

Pass as a callback — every LLM call is traced automatically.

Why This Matters

You’re not debugging a function. You’re debugging a probabilistic system. Every prompt change is a hypothesis test.

Today you run that test by eyeballing outputs. agent-lens makes it structural, repeatable, and recorded.

Vibes-based prompt engineering is debugging without a debugger.
agent-lens is the debugger.

GitHub: https://github.com/RAJUSHANIGARAPU/agent-lens
Install: pip install agentlens-tracer

Would love to hear how you’re currently debugging LLM agents — drop a comment below.

I Built a Free Image Compressor That Never Uploads Your Files

The Problem

Every time you use an online image compressor, your files get uploaded to someone else’s computer. TinyPNG does it. Compressor.io does it. Even most so-called “free” tools collect your data on their servers.

This has always felt wrong to me. Images can contain sensitive stuff — screenshots of conversations, private photos, business documents, unreleased designs. Why should making them smaller require handing them to a stranger?

The Solution: Squash

Squash is a free, open-source image compressor that runs entirely in your browser.

  • 🚫 No uploads — everything stays on your device
  • ⚡ Instant compression — no waiting for network round-trips
  • 🎨 Multi-format — JPEG, PNG, WebP support
  • 📦 Batch processing — compress multiple images at once
  • 🎚️ Quality slider — full control from 1% to 100%
  • 📐 Resize — set max dimensions while compressing
  • 🌓 Dark mode — works great at night
  • 💰 Completely free — no limits, no watermarks

How It Works

Squash uses the browser Canvas API to decode images, apply compression settings, and re-encode them at your chosen quality level. All the heavy lifting happens on your device hardware — not on some server farm.

The processing pipeline:

  1. Load — Image is decoded from file into raw pixel data
  2. Resize — If a max width is set, image is scaled down
  3. Encode — Pixel data is re-encoded at the chosen quality and format
  4. Download — The compressed result is ready to save

No server. No upload. No privacy concern.

Why I Built This

I wanted a tool that respects privacy. Not as a marketing slogan — as a technical guarantee. Your images literally cannot be uploaded because there is no server to upload them to. The entire application is static HTML, CSS, and JavaScript served from GitHub Pages.

The source code is open (MIT license). You can inspect every line. You can host it yourself. You can verify that nothing leaves your browser.

Comparison with Other Tools

Tool Privacy Batch Mode WebP Support Price
Squash ✅ Local only ✅ Yes ✅ Yes Free
Squoosh ✅ Local only ❌ No ✅ Yes Free
TinyPNG ❌ Uploads ✅ Yes ✅ Yes Free (20/day)
Compressor.io ❌ Uploads ✅ Yes ❌ No Free (10MB cap)

The key difference: Squash and Squoosh process locally. But Squoosh has no batch mode. Squash is the only tool that combines local processing + batch mode + multi-format + unlimited use.

Tech Stack

  • Vanilla HTML/CSS/JavaScript — No frameworks, no dependencies
  • Canvas API — Browser-native image processing
  • GitHub Pages — Free, fast static hosting
  • Zero backend — No server, no database, no API calls

Try It Yourself

👉 Launch Squash

📂 Source Code on GitHub

If you find it useful, consider buying me a coffee ☕ — it helps keep the project alive and improving.

Built with vanilla HTML/CSS/JS. No frameworks, no dependencies, no build step. MIT licensed.

How to Use grep, awk, and sed Like a SysAdmin

How to Use grep, awk, and sed Like a SysAdmin

If you work with Linux, you will eventually spend time reading logs, searching configuration files, and cleaning messy text output.

At first, many people try to do this manually.

Open file. Scroll. Search. Copy text. Repeat.

That works for small tasks.

But sysadmins do not work that way.

They use command-line tools that solve problems in seconds.

Three of the most useful tools are:

  • grep
  • awk
  • sed

If you learn these well, your Linux troubleshooting speed improves a lot.

This post shows practical examples, not textbook definitions.

How to Use grep, awk, and sed Like a SysAdmin

Why These Three Commands Matter
Imagine these real situations:

You want to find failed SSH login attempts.

You want to extract IP addresses from logs.

You want to replace a wrong server name in a config file.

You want to count repeated entries.

Doing this manually wastes time.

This is where these commands help.

Think of them like this:

  • grep = find text
  • awk = extract and process columns
  • sed = edit and transform text

1. grep: Find What Matters Fast
grep searches for matching text.

Basic syntax:

grep "pattern" filename

Example:

grep "error" app.log

Output:

database connection error
api timeout error

This finds lines containing the word error.

Useful grep Options

Ignore case

grep -i "error" app.log

Matches:

  • Error
  • ERROR
  • error

Helpful when log formats are inconsistent.

Show line numbers

grep -n "server" nginx.conf

Output:

12:server_name example.com;
45:server_tokens off;

Good for config debugging.

Invert match

Show lines that do NOT match:

grep -v "INFO" app.log

Useful when removing noisy logs.

Recursive search

Search inside directories:

grep -r "Listen 80" /etc/apache2

Very useful for config hunting.

Real SysAdmin Example

Find failed SSH login attempts:

grep "Failed password" /var/log/auth.log

Sample output:

Failed password for root from 192.168.1.10
Failed password for admin from 10.0.0.5

This quickly shows suspicious login attempts.

2. awk: Extract and Process Data

awk is excellent when data has columns.

Example file:

alice 5000 IT
bob 7000 HR
john 6500 DevOps

Print first column:

awk '{print $1}' employees.txt

Output:

alice
bob
john

Explanation:

  • $1 = first column
  • $2 = second column
  • $3 = third column

Print Multiple Columns

awk '{print $1, $3}' employees.txt

Output:

alice IT
bob HR
john DevOps

Filter by Condition

Show salaries above 6000:

awk '$2 > 6000 {print $1, $2}' employees.txt

Output:

bob 7000
john 6500

This is very useful for reports.

Real SysAdmin Example

Check logged-in users:

who

Example output:

pawan pts/0 2026-05-18 10:30
john pts/1 2026-05-18 11:00

Extract usernames:

who | awk '{print $1}'

Output:

pawan
john

3. sed: Stream Editing Made Simple

sed helps modify text.

Basic replacement:

sed 's/old/new/' file.txt

Example:

sed 's/dev/prod/' config.txt

If file contains:

server=dev

Output:

server=prod

Replace All Matches

Without global flag, only first match changes.

Use:

sed 's/error/warning/g' app.log

g = global replacement

Delete Lines

Delete blank lines:

sed '/^$/d' file.txt

Very useful when cleaning files.

Edit File Directly

sed -i 's/localhost/db-server/' config.ini

Be careful.

This changes the actual file.

Real SysAdmin Example

Update nginx config:

Before:

server_name oldsite.com;

Command:

sed -i 's/oldsite.com/newsite.com/' nginx.conf

Fast and practical.

Combining Commands Like a SysAdmin

The real power comes from combining tools.

Example:

Find failed SSH attempts and extract IP addresses:

grep "Failed password" /var/log/auth.log | awk '{print $11}'

Output:

192.168.1.10
10.0.0.5

Count repeated IPs:

grep "Failed password" /var/log/auth.log | awk '{print $11}' | sort | uniq -c

Sample output:

5 192.168.1.10
2 10.0.0.5

Now you know which IP is attacking most.

This is real troubleshooting.

Common Beginner Mistakes

Using grep for everything

Yes, grep is useful.

But if you need column processing, use awk.

Editing files with sed without backup

This:

sed -i 's/test/prod/' file.conf

changes the file immediately.

Safer:

cp file.conf file.conf.bak

then edit.

Forgetting quotes

Wrong:

grep error file.txt

Better:

grep "error" file.txt

Especially for complex patterns.

Quick Comparison

Tool Best Use
grep Search matching text
awk Extract/process columns
sed Replace/edit text

Let’s build some full-stack apps

enable firesbase

Meet your backend assistant
In AI Studio, you don’t have to hassle with database provisioning and set up. The AI agent is smart enough to notice when your app needs to save data. Whether you’re building a simple to-do list or a complex client portal, it proactively sets up Cloud Firestore for you so you can stay in your creative flow.

3 steps to a full-stack app

1. Ask for a feature that requires stored data
In AI Studio Build mode, describe what you want your app to remember. You might say, “Add a sign-up form that saves names and email” or “Let users save their favorite recipe.” The agent will immediately recognize that you need a database.

2. Approve the Setup
As the AI agent processes your request, it will proactively detect that your app needs a backend to save data. A card will appear in the chat panel asking to Enable Firebase. Simply click the “Enable Firebase” button, and the agent will automatically provision Cloud Firestore and link it to your project.

enable firesbase

3. Launch Your New App
Once your app is ready, hit the Share button to generate a full-screen preview link. When you’re ready to move to a permanent home, you can deploy your project directly to Google Cloud. This takes your creation and puts it on a live URL that’s ready for your users.

E-invoice compliance coming into force in 2026

E-invoicing compliance is becoming a real challenge for ecommerce merchants across Europe 🇪🇺

For Shopify merchants, the order data is already there: customer details, products, VAT, totals, dates, and payment information.

But in some countries, that data now needs to be turned into structured, compliant invoice formats.

That is why we built Billoop 🧾

Billoop is a Shopify app that helps merchants generate compliant invoice files directly from Shopify order data, starting with France-ready Factur-X invoices.

What it helps with:

✅ Generate compliant invoice files from Shopify orders
✅ Reduce manual invoice work
✅ Prepare for changing EU e-invoicing rules
✅ Keep the workflow close to the Shopify order page
✅ Support country-specific formats, starting with France
✅ German-compliant invoices are on the way

We kept Billoop focused on one practical problem:

Shopify merchants should not need to fight with invoice compliance manually when the order data already exists.

Billoop turns that data into the invoice format they need.

You can check it out here 👇
Shopify app store
Website

Would love feedback from Shopify merchants, agencies, and anyone working with ecommerce invoicing or EU compliance