Questioning productivity with AI coding agents in enterprise environments

People often have an assumption that productivity gains from AI coding agents are going to come from generating code, which in turn will allow us to complete more tasks. I think that this is the wrong approach to sustainable productivity gains with AI in enterprise environments.

First of all, coding speed is rarely the bottleneck in many enterprise environments. If we imagine that the time taken to implement the code for a given task goes to zero, the bottleneck would immediately become quality assurance (QA) and code review. This of course assumes that the task is well specified upfront, and that other teams work are not blocking the changes.

I rarely hear developers say that code review is the favorite part of their work, and it’s the same with QA. Yet we spend our focus on generating code with AI agents that we have to sift through and review, before giving our poor coworkers the same “joy” of reviewing soulless AI-generated code.

Shouldn’t our focus rather be on using AI agents to improve our code review and QA experience, so we are left with the fun part – building?

For example – my favorite way of going through pull requests is to sit together with the PR author and talk through the changes. I usually have a lot of questions, and it’s much faster to get answers in real time than going back and forth in comments. The agent can answer ~90% of my questions and even prototype alternative solutions. That means I show up to the review with fewer, but much higher-quality questions. It’s a much better experience for both sides.

I used to focus on using AI to generate code. Now I think the bigger opportunity is using AI to improve the thinking around the code-review, validation, and decision-making.

Are we over-optimizing for code generation and ignoring the real bottlenecks?

JavaScript Closures Explained Simply

Let’s be honest.

Closures in JavaScript sound scary at first.
You’ve probably seen definitions like:

“A closure is a function that retains access to its lexical scope…”

And your brain immediately goes: Nope.

But here’s the truth — closures are not complicated. You’re already using them. You just don’t realize it yet.

So let’s break it down slowly, clearly, and in a way that actually sticks.

First — don’t scroll

Look at this code.

function outer() {
  let name = "Alex";

  return function inner() {
    console.log(name);
  };
}

const fn = outer();
fn();

What do you think the output is?

Don’t skip. Think.

.

.

.

Now imagine you bet money on your answer.

Output:

Alex

If that surprised you even a little… good. You’re in the right place.

Now here’s the real question

Why is name still available?

outer() is already done. Gone. Finished.

So why didn’t name disappear with it?

If your answer is:

“Because JavaScript is weird”

No. That’s just avoiding the truth.

Or worse — memorizing without understanding.

This is where closures hit you

A closure means:

A function remembers variables from where it was created — not where it runs.

Read that again.

Not where it runs.
Where it was created.

If that still feels abstract, don’t worry — I’m going to corner you with examples.

Still not convinced? Let’s push you

Real scenario: Login system

function createLogin(username, password) {
  return function (inputPassword) {
    if (inputPassword === password) {
      console.log(`Welcome ${username}`);
    } else {
      console.log("Wrong password");
    }
  };
}

const login = createLogin("Sam", "pass123");

login("pass123");
login("123");

Output:

Welcome Sam
Wrong password

Now answer this:

Where is password stored?

  • Not global
  • Not inside login
  • Not accessible anywhere

Yet it still works.

And no, it’s not “saved somewhere magically.”

That’s closure.

And if that didn’t click yet, don’t worry — I’m not done with you.

Let’s corner you a bit

Try this:

console.log(login.password);

Before you run it — predict it.

Output:

undefined

So…

  • You can’t access it
  • But the function can

Sounds like magic?

It’s not magic. It’s memory + scope working together.

Real-world situation: You click a button

Let’s say you’re tracking clicks.

function createClickTracker() {
  let count = 0;

  return function () {
    count++;
    console.log(`Clicked ${count} times`);
  };
}

const click = createClickTracker();

click();
click();
click();

Output:

Clicked 1 times
Clicked 2 times
Clicked 3 times

Now don’t just read.

Answer this:

Why is count not resetting to 0?

Pause. Actually think.

Because you’re not creating count again.
You’re reusing the same closed-over variable.

Let’s make it uncomfortable

What if I do this:

const click1 = createClickTracker();
const click2 = createClickTracker();

click1();
click1();

click2();

Before you scroll — predict it.

Output:

Clicked 1 times
Clicked 2 times
Clicked 1 times

Now think carefully.

If closures were “shared memory”… this wouldn’t happen.

Each function has its own private world.

Yes — like separate tabs in your browser.

Real app logic: Shopping cart

function createCart() {
  let items = [];

  return {
    add(item) {
      items.push(item);
      console.log(`${item} added`);
    },
    view() {
      console.log(items);
    }
  };
}

const cart = createCart();

cart.add("Laptop");
cart.add("Phone");
cart.view();

Output:

Laptop added
Phone added
["Laptop", "Phone"]

Now I dare you:

console.log(cart.items);

Before running it — what do you expect?

Output:

undefined

So let me ask you:

If you can’t access items, how is it still updating?

Because it lives inside a closure.

Not global. Not exposed. Not reachable.

Hidden — but alive.

Now let’s catch a bug (you’ve probably made this)

for (var i = 1; i <= 3; i++) {
  setTimeout(function () {
    console.log(`Order ${i}`);
  }, 1000);
}

What do you expect?

Be honest.

Order 1
Order 2
Order 3

What actually happens?

Order 4
Order 4
Order 4

Yeah. That bug.

Don’t say “I wouldn’t make that mistake.”
You either already have — or you will.

Fix it using closure

for (var i = 1; i <= 3; i++) {
  (function (order) {
    setTimeout(function () {
      console.log(`Order ${order}`);
    }, 1000);
  })(i);
}

Output:

Order 1
Order 2
Order 3

Each loop creates a new memory.

Same code pattern. Different outcome.

That’s the twist closures bring.

Or use modern JS (yes, this is easier)

for (let i = 1; i <= 3; i++) {
  setTimeout(function () {
    console.log(`Order ${i}`);
  }, 1000);
}

Same correct output.

Because let creates a new scope every time.

Different keyword. Completely different behavior.

Pause. Don’t scroll yet.

If I ask you now:

What is a closure?

Don’t repeat a definition.

Say it like a developer.

Say it like you actually understand it.

The version you should remember

A closure is when a function carries its data with it, even after the original function is gone.

Not stored globally.
Not recreated.
Carried.

Quick self-check (no cheating)

What will this print?

function createDiscount(discount) {
  return function (price) {
    return price - price * discount;
  };
}

const discount10 = createDiscount(0.1);

console.log(discount10(200));

Don’t rush. Think.

.

.

.

Output:

180

If you got that right, you’re not guessing anymore.

If you didn’t — good. That means you’re actually learning instead of memorizing.

Final reality check

Closures are not some “advanced topic.”

They are used in:

  • React hooks
  • Event listeners
  • API handlers
  • Timers
  • State management

So if you skip this…

You’re not skipping theory.
You’re skipping how JavaScript actually works.

One last push

Next time you see:

  • Function inside function
  • Returned function
  • Hidden variables
  • Values that “don’t reset”

Don’t ignore it.

Stop and ask:

“What is this function remembering?”

That question alone will level you up.

And now you actually understand closures — not just pretend to.

How We Generated 3,066 Colors Algorithmically — No Database Needed

Most color tools ship with a hand-picked palette of a few hundred swatches stored in a database or JSON file. We took a different approach with ColorArchive: every single color is generated at build time from three small arrays and some HSL math. No database, no API call, no JSON blob checked into the repo. Just code.

Here’s how — and what we learned along the way.

The Problem with Curated Palettes

Manually curating colors doesn’t scale. You either end up with too few choices (Material Design’s 256 colors leave gaps) or an unstructured dump that’s hard to navigate. We wanted something systematic: a color space that’s dense enough for real design work but organized so every color has a predictable, human-readable name.

The constraint we set for ourselves: the entire dataset must be reproducible from a single function call with zero external dependencies.

Three Arrays, 3,066 Colors

The system breaks down into three dimensions mapped onto HSL (Hue, Saturation, Lightness):

// 36 hue roots — one every 10 degrees around the color wheel
const hueCatalog = [
  { hue: 0,   root: "Crimson" },
  { hue: 10,  root: "Ruby" },
  { hue: 20,  root: "Ember" },
  { hue: 30,  root: "Coral" },
  // ... 32 more, through Garnet (340) and Merlot (350)
];

// 14 lightness bands — poetic names for brightness levels
const lightBands = [
  { label: "Veil",     lightness: 98 },
  { label: "Whisper",  lightness: 94 },
  { label: "Mist",     lightness: 90 },
  { label: "Pearl",    lightness: 84 },
  { label: "Bloom",    lightness: 76 },
  { label: "Silk",     lightness: 68 },
  { label: "Tone",     lightness: 60 },
  { label: "Radiant",  lightness: 54 },
  { label: "Core",     lightness: 48 },
  { label: "Velvet",   lightness: 42 },
  { label: "Dusk",     lightness: 34 },
  { label: "Shadow",   lightness: 28 },
  { label: "Nocturne", lightness: 20 },
  { label: "Ink",      lightness: 14 },
];

// 6 chroma bands — saturation intensity
const chromaBands = [
  { label: "Faint", saturation: 10 },
  { label: "Muted", saturation: 18 },
  { label: "Soft",  saturation: 34 },
  { label: "Clear", saturation: 54 },
  { label: "Vivid", saturation: 74 },
  { label: "Pure",  saturation: 92 },
];

The math is straightforward: 36 hues x 14 lightness x 6 chroma = 3,024 chromatic colors. We add 42 neutral grays (3 gray roots x 14 lightness bands, no chroma suffix) to reach 3,066 total.

The Generator Function

The entire dataset is created by a single flatMap call:

const chromatic = hueCatalog.flatMap(({ hue, root }) =>
  toneCatalog.map(({ suffix, saturation, lightness }) => {
    const name = `${root} ${suffix}`;
    const rgb = hslToRgb(hue, saturation, lightness);

    return {
      id: createColorId(name),  // "amber-pearl-muted"
      name,                      // "Amber Pearl Muted"
      hex: rgbToHex(rgb),
      rgb: formatRgb(rgb),
      hsl: formatHsl(hue, saturation, lightness),
      hue,
      saturation,
      lightness,
      family: getColorFamily(hue),
    };
  })
);

Every color gets a deterministic ID derived from its name: amber-pearl-muted, cobalt-shadow-vivid, emerald-bloom-clear. The naming convention means you can guess a color’s ID without looking it up — if you know the root, lightness band, and chroma band, you know the ID.

Why HSL Instead of LCH or OKLCH?

We considered perceptually uniform color spaces like OKLCH. They produce more visually consistent results across hues, and they’re the future of CSS color. But we chose HSL for pragmatic reasons:

  1. Universal browser support. HSL works everywhere today without polyfills.
  2. Simpler mental model. Designers intuitively understand “rotate the hue wheel, slide lightness up/down, adjust saturation.” The three-dimensional grid maps cleanly onto three HSL parameters.
  3. Predictable IDs. Since each axis is independent, you can mentally construct any color in the system.

The tradeoff is that some hues (especially yellows and cyans) appear perceptually brighter than others at the same lightness value. We accepted this because the naming system — Veil through Ink, Faint through Pure — gives designers enough vocabulary to find what they need regardless.

Static Generation at Scale

Since every color is deterministic, we use Next.js generateStaticParams() to pre-render all 3,066 color detail pages at build time:

export function generateStaticParams() {
  return colors.map((color) => ({ slug: color.id }));
}

This means every color page — with its hex/RGB/HSL values, export snippets, related colors, and accessibility contrast ratios — is a static HTML file served from the CDN. No server-side rendering, no database queries at runtime. The build takes a few minutes, but pages load instantly.

What This Unlocks

Once your colors are algorithmic, interesting things become possible:

  • Relationships are computable. Analogous colors are adjacent hue roots. Complementary colors are 180 degrees away. Tonal companions share lightness and chroma but vary in hue. All of this is just math on the same arrays.
  • Export formats are trivial. Generating CSS custom properties, Tailwind config objects, Figma design tokens, SwiftUI Color() calls, or Jetpack Compose values is just a different map() over the same dataset.
  • Collections are lightweight. Our 255+ curated collections are just arrays of color IDs. The actual color data is always derived, never duplicated.
  • Search is instant. Filtering 3,066 records in memory is fast enough that we don’t need a search server. Filter by family, lightness range, or chroma — it’s all client-side array operations.

Lessons Learned

Naming is the hardest part. We iterated on the lightness band names more than anything else. Early versions used generic labels like “Light” and “Dark.” The poetic names (Veil, Whisper, Nocturne, Ink) turned out to matter for usability — designers remember “Cobalt Nocturne Vivid” far more easily than “Cobalt L20 S74.”

Don’t underestimate the grid. 36 x 14 x 6 sounds small, but 3,066 colors with individual pages, plus hundreds of guides and collections built on top, means your build pipeline and page architecture need to be solid from day one.

Algorithmic doesn’t mean cold. We were worried a generated color system would feel mechanical. Adding the curated layer on top — editorial collections, design guides, palette packs — brings the warmth. The algorithm handles the grunt work; human taste handles the curation.

Try It Out

ColorArchive is free to browse, no signup required. Explore the full spectrum, grab export snippets for your stack, or build palettes with the interactive tools.

Check it out at colorarchive.me.

Meaningful Domain Models in TypeScript

Most bugs I’ve seen in production weren’t caused by wrong algorithms or bad infrastructure. They were caused by invalid state — an order with no items, a paid order without an invoice ID, a payment that spent more than expected.

The code was syntactically correct. TypeScript was happy. But the business rules were silently violated.

This post explores a few patterns that help you build domain models that make invalid states impossible — and when something does go wrong, make failures explicit.

The problem with implicit rules

Consider a typical order entity:

class Order {
  status: "DRAFT" | "PLACED" | "PAID";
  items: OrderItem[];
  invoiceId?: string;
}

Nothing stops you from creating a PAID order with no invoiceId. Nothing stops you from removing the last item. The rules exist in your head (or a comment somewhere), not in the code.

You might add validation in one place — a use case, a controller — but nothing prevents another developer (or future you) from mutating state from a different path and bypassing it entirely.

Invariants: rules that are always true

An invariant is a business rule that must hold at all times, not just after specific operations.

Instead of validating in one place and hoping nothing else touches the state, you declare the rule once and it is checked every time state is read:

// This rule is always enforced — no matter how state was mutated
const paidOrderHasInvoiceId = new BaseDomainInvariant<OrderState>(
  "Paid Order Has Invoice Id",
  (state) => {
    if (state.status === "PAID") {
      return state.invoiceId !== undefined;
    }
    return true;
  }
);

The entity registers its invariants in the constructor:

class Order extends DomainEntity<OrderState> {
  private constructor(id: string, state: OrderState) {
    super(id, state);
    this.addInvariant(orderHasAtLeastOneItem);
    this.addInvariant(paidOrderHasInvoiceId);
  }
}

Any time you call order.readState(), the invariants are checked. If any is violated, it throws "Corrupted state detected". You can never silently read a broken entity.

Invariants are also composable:

const complexRule = invariantA.and(invariantB).or(invariantC);

Explicit failures with the Result pattern

TypeScript lets you throw anything, anywhere. But throw is invisible in function signatures — callers have no idea what can go wrong unless they read the implementation (or discover it in prod).

The Result pattern makes failures a first-class return value:

lockCredits(params: { amount: number }): Result<CreditLocked, NotEnoughFunds> {
  if (this.state.subCreditBalance < params.amount) {
    return err(new NotEnoughFunds(
      `Not enough credits`,
      { available: this.state.subCreditBalance, amount: params.amount }
    ));
  }

  this.state.lockedBalance += params.amount;
  return ok(new CreditLocked(this.id(), { amount: params.amount }));
}

The return type tells you everything: this operation either succeeds with a CreditLocked event, or fails with a NotEnoughFunds domain error. No surprise exceptions. The caller is forced to handle both cases:

const result = creditBalance.lockCredits({ amount: 100 });

if (result.isErr()) {
  // result.error is typed as NotEnoughFunds
  console.log(result.error.context.available);
} else {
  // result.value is typed as CreditLocked
  await repository.saveWithEvents(creditBalance, result.value);
}

Typed errors also carry structured context, not just a message string — making logging and debugging much more useful.

Domain events: immutable facts

Every state change produces an event — an immutable, versioned record of what happened:

export class OrderPaid extends DomainEvent<"ORDER_PAID", 1, { invoiceId: string }> {
  constructor(entityId: string, payload: { invoiceId: string }) {
    super({ name: "ORDER_PAID", version: 1, entityId, payload });
  }
}

Operations return their event:

pay(params: { invoiceId: string }): Result<OrderPaid, InvalidStatusTransition> {
  if (this.state.status !== "PLACED") {
    return err(new InvalidStatusTransition(...));
  }
  this.state.status = "PAID";
  this.state.invoiceId = params.invoiceId;
  return ok(new OrderPaid(this.id(), { invoiceId: params.invoiceId }));
}

You persist both the entity state and its events atomically:

const result = order.pay({ invoiceId: "INV-123" });
if (result.isOk()) {
  await repository.saveWithEvents(order, result.value);
}

This gives you a full audit trail with no extra effort. Events are also great for triggering side effects (emails, webhooks, projections) in a decoupled way.

Putting it together: a use case

Here’s a complete payOrder use case that composes all these patterns:

export async function payOrderUseCase(
  repository: OrderRepository,
  id: string,
  invoiceId: string,
): Promise<Result<OrderState, InvalidStatusTransition | EntityNotFound>> {

  const getResult = await repository.getById(id);
  if (getResult.isErr()) throw getResult.error; // infra error, not domain

  const order = getResult.value;
  if (order === undefined) {
    return err(new EntityNotFound("Order not found", { entityId: id }));
  }

  const payResult = order.pay({ invoiceId });
  if (payResult.isErr()) return err(payResult.error);

  const saveResult = await repository.saveWithEvents(order, payResult.value);
  if (saveResult.isErr()) throw saveResult.error;

  return ok(order.readState());
}

Notice how each kind of failure is handled differently:

  • Infrastructure errors (DB down): throw — these are exceptional, not domain logic
  • Domain failures (wrong status): returned as typed Result — callers handle them

The library

All of these patterns are available in Ontologic — a small TypeScript toolkit that provides DomainEntity, BaseDomainInvariant, DomainEvent, DomainError, Result, and a Repository interface.

npm install ontologic

It doesn’t try to be a framework — no decorators, no magic, no IoC container required. Just plain TypeScript classes and types that you can drop into any project.

Full docs and examples (including the Order and CreditBalance aggregates shown above) are at ontologic.site.

These patterns aren’t new — they come from Domain-Driven Design and functional programming. But they’re underused in TypeScript codebases, where it’s easy to reach for throw and optional fields instead.

The payoff is code that reads like business rules, fails loudly when something is wrong, and makes it hard to put entities into states that shouldn’t exist.

I created an iOS baby book tracker app with React Native

Baby Book Tracker is on the App Store for iPhone and iPad.

Download on the App Store

Read more at https://babybooktracker.montalesi.dev/

Since before my son was born I was aware of the importance of reading to him.
While browsing online I stumbled upon the 1000 books before 5 challenge and looked for apps that would help me participate in it.

I found a couple but they didn’t really satisfy my needs because many of the books that I own didn’t appear when I tried scanning them and the app I was using wouldn’t let me add my own.

I decided to use Notion for a while but that was not the best UX and was not very engaging.

My field of expertise is Enterprise SaaS, not really iOS development but with some help from GPT Codex and Claude Code I was able to quickly iterate and get a MVP done for my own app.

This was my first time “vibe coding” an entire app from scratch and overall it was an interesting experience.

I still had to do a lot of reading about React Native to ensure the LLMs were not doing something crazy but after a few back and forth I ended up with a product I was very satisfied with.

When setting up to create this app I wanted two things especially:

  • privacy
  • no need for an account

I wanted the user to own the data and also I wanted them to have something they could own and use forever once paid instead of being forced on a subscription plan or having to navigate through banner full of ads.

The app is pretty simple, I will now describe a bit what it does and the stack and UI.

What it does

  • Baby profiles: one profile per child, supports multiple kids
  • Book library: shared across all babies so you don’t re-enter titles
  • Barcode scanning: point at the ISBN on any book and it auto-fills everything from an open book database
  • Manually add books: if you can’t scan them, you can manually add them
  • Reading log: pick the baby, book, reader, and date. Takes about 10 seconds
  • Stats: streaks, total sessions, unique books, weekly activity chart
  • Share card: exportable image with your baby’s reading stats
  • Privacy-first: everything stays on device by default, optional iCloud sync, no account needed

Tech stack

  • React Native 0.81 with New Architecture enabled
  • Expo SDK 54 and Expo Router v6
  • React 19
  • TypeScript
  • i18next for localization (EN, IT, DE, FR, ES, JP, KO)
  • RevenueCat for in-app purchases
  • CloudKit for optional iCloud sync, no backend required
  • Vitest for tests
  • EAS Build for CI/CD and App Store releases

The screens

Home

Recent sessions, today’s reading summary, and a big “Log a session” button. The goal was to make logging fast enough that you’d actually do it.

Books

Your full library with cover art and read counts. Tap a book to see who read it, when, and to which baby. The barcode scanner lives here too.

History

A full log of every session, filterable by baby, book, or reader. Useful for settling the “I read more than you” debate with your partner.

Stats

Current streak, total sessions, unique books, and a chart of activity over time. Probably the screen I open most.

Baby detail and share card

A profile page per baby with a shareable image card you can save or send.

A few technical decisions

Barcode scanning was the make-or-break feature. Manually entering a book title every single time would kill the habit fast. Scanning an ISBN takes two seconds and pulls the title, author, cover, and page count automatically. Built with expo-camera.

CloudKit instead of a backend was an easy call. Sync goes through the user’s own iCloud account so I never touch the data, there’s nothing to maintain, and it costs nothing to run.

i18n from day one paid off. Every string goes through i18next, so adding 6 more languages later was a translation job rather than a refactor. App Store metadata is kept in the same Markdown source and exported automatically during releases.

Scripted releases mean the whole pipeline runs in one command: bump version, build IPA with EAS, export metadata, upload to App Store Connect. No Xcode, no clicking around in App Store Connect.

On vibe coding a real app

AI-assisted development works surprisingly well for a project like this. The models have clearly seen a lot of Expo and React Native code. Things like wiring up routing, integrating third-party SDKs, scripting the release pipeline, most of it came out nearly ready to use.

Where it struggled was anything with complex local state or subtle iOS UI quirks like keyboard avoidance and bottom sheet interactions with system gestures. Those needed more back-and-forth or I just wrote them myself.

The main thing I learned is that prompting for code is a skill. Being specific about your setup and pasting in the relevant existing code makes a big difference. Think of it like onboarding a strong dev who just needs good context to do good work.

I found Codex to be better at reasoning and finding weird bugs and Claude better at doing most of the work.

Try it

Baby Book Tracker is on the App Store for iPhone and iPad.

Download on the App Store

Read more at https://babybooktracker.montalesi.dev/

Below is a short demo video of the app, it’s not the most recent build but you get the idea of how it works

Cover image from: https://unsplash.com/photos/father-and-son-reading-a-book-together-on-couch-kWFA09iZmGI

How Linux Powers Real-World Data Engineering

Linux Isn’t Optional. It’s the Foundation.

If you work in data engineering, you might spend most of your day inside managed cloud consoles and PaaS dashboards. It’s easy to forget what’s running underneath. But peel back those abstractions and you’ll find Linux everywhere. AWS, GCP, and Azure all run on Linux distributions to provision compute instances, manage virtualization, and orchestrate containers. For data engineers building and maintaining resilient pipelines, Linux proficiency isn’t a nice-to-have. It’s table stakes.

Not long ago, enterprise data integration meant dragging and dropping in GUI-based ETL tools like SQL Server Integration Services (SSIS), typically on Windows servers. Those tools worked fine for basic pipelines, but they buckled under the scalability and automation demands of big data. As organizations scaled, they gravitated toward open-source Linux distributions (Red Hat Enterprise Linux, CentOS, Ubuntu), drawn by their stability, security, and resource efficiency.

The entire modern distributed processing stack was born on Linux. Hadoop, Spark, Kafka, Airflow. All of them depend on Linux kernel features for memory management, disk I/O, and concurrent processing across clusters. To administer these tools effectively, you need the command line. You SSH into servers, edit DAGs, inspect execution logs, manage background scheduling, and debug production failures in real time. An engineer with strong Linux skills immediately signals that they can manage the full data lifecycle at the system level.

The Terminal: Your Primary Data Interface

The Linux terminal, whether you call it the console, the shell, or the command prompt, is the direct line between you and the operating system kernel. Unlike graphical interfaces that abstract system calls away, the terminal gives you unfiltered access to the filesystem, network interfaces, and process schedulers.

In data engineering, where batch tasks and high-volume data manipulation are constant, this matters. You can kick off a multi-terabyte download in one terminal window while running log analysis in another, with the kernel handling the multitasking without breaking a sweat. And when a GUI crashes due to memory exhaustion or a bad config, the CLI is often the only way back in.

Navigating the Filesystem

The filesystem is your initial staging area: the place where raw data lands before it gets loaded into a database or distributed file system. You’ll navigate it with the basics: pwd to check where you are, cd to move around, ls to see what’s there. In practice, you’ll lean on flags constantly. Running ls -alF (often aliased to ll) gives you a comprehensive view: hidden files, byte-level sizes, ownership, and permissions all at a glance.

For exploring complex directory structures, tree visualizes the hierarchy of your data partitions. The pushd/popd stack commands let you dive deep into nested log directories and snap right back to where you started.

Once you’re in the right directory, file manipulation takes over. cp duplicates raw data for backup before transformation. mv renames files or shifts them across partitions after processing. mkdir creates new directories on the fly for daily partitioned extracts. touch is subtly versatile: its primary job is updating timestamps, but pipelines frequently use it to create empty marker files (like _SUCCESS flags) that signal to downstream orchestration sensors that an upstream job completed. And rm permanently deletes files, an operation that demands caution, especially when you’re automating the purging of old staging data.

Access Control, Security, and System Management

Production environments are multi-tenant. Strict access control isn’t optional; it’s required for data governance and security compliance. You need to manage who can view sensitive datasets, modify transformation scripts, or execute pipeline triggers.

chmod is the primary tool for managing file permissions. Before a freshly written ETL shell script can run, you must explicitly grant execution rights: chmod a+x etl_pipeline.sh or chmod 755 script.sh. File ownership is managed through chown (change owner) and chgrp (change group), ensuring only authorized service accounts (like the airflow or spark user) can access specific data partitions. In complex enterprise setups, setfacl creates Access Control Lists (ACLs) that go beyond the standard owner/group/others model.

When you need admin privileges (installing dependencies, restarting daemons), sudo temporarily elevates your permissions to root. The su command lets you switch your entire shell session to another user, which is invaluable when testing whether a service account has the right permissions to run a pipeline.

A few other tools deserve mention here. history is essential for auditing previously executed commands when something breaks. who shows logged-in users, letting you verify no unauthorized connections exist on a sensitive database server. For finding files across sprawling filesystems, find does deep real-time traversal, while locate (paired with updatedb) offers near-instant searches against a pre-built index. And when you’re editing config files on a remote server with no GUI, nano handles straightforward edits, while vim, steep learning curve and all, enables lightning-fast text manipulation once you’ve internalized the keybindings.

The Command Line as a High-Throughput ETL Engine

Before data reaches your data warehouse or processing framework, it usually needs inspection, cleansing, and formatting. Python is the standard for complex transformations, but the Linux shell provides a suite of text-processing utilities that act as a remarkably fast, memory-efficient ETL engine. These tools are written in C and process data as continuous streams rather than loading entire files into memory, so they often outperform scripted solutions when filtering or aggregating gigabyte-scale flat files.

Inspecting and Aggregating Data

Understanding your data starts with looking at it. cat prints an entire file to stdout, but on massive datasets it’ll overwhelm your terminal. Instead, use head to sample the first few rows (checking headers and schema alignment) and tail to inspect the end of a file. The tail -f flag is indispensable for monitoring real-time application logs. For full exploration, less gives you paginated viewing, letting you scroll forward and backward through massive files without the memory cost of loading the whole thing.

To validate data completeness after a network transfer, wc -l counts lines instantly, letting you confirm that extracted row counts match expectations. The file command analyzes a file’s magic numbers to determine its actual type and encoding, which is essential for catching a mislabeled binary masquerading as a CSV.

Stream Editing and Relational Operations

The real power of the Linux shell comes from standard streams (stdin, stdout, stderr) and the pipe operator (|). Piping lets you chain the output of one utility directly into the next, building multi-stage data processing workflows entirely in the terminal.

Here’s how common Linux text utilities map to SQL operations:

Linux Command What It Does SQL Equivalent
grep Filters rows matching string patterns or regex WHERE
cut Extracts specific columns by delimiter SELECT
awk Line-by-line processing with conditionals and arithmetic SELECT with calculated columns
sed Stream editing: find and replace with regex REPLACE() / string functions
sort Orders lines alphabetically or numerically ORDER BY
uniq Removes adjacent duplicates; -c adds frequency counts DISTINCT / GROUP BY
paste Merges lines from multiple files side by side Horizontal JOIN

These utilities chain together beautifully. Say you need to find the most frequent IP addresses generating 500 errors in a web server log. Instead of writing a Python script:

cat error.log | grep '500 Internal Server Error' | awk '{print $1}' | sort | uniq -c | sort -nr

That single line filters for 500 errors, extracts the IP address column, sorts to group duplicates, counts occurrences, and sorts the results in descending order. Append tee to display results on screen while simultaneously writing to an audit file.

Parallel Processing: Xargs and GNU Parallel

Sequential pipe chains are elegant and memory-efficient, but they’re single-threaded. When you’re processing thousands of log files or migrating large repositories, you need parallelism.

Xargs

xargs reads items from stdin, parses them into arguments, and feeds them to another command. This solves a fundamental Unix constraint: many commands don’t accept stdin natively, and passing extremely long argument lists via wildcard expansion triggers the dreaded Argument list too long (ARG_MAX) error.

If you need to delete millions of temporary staging files, rm -f *.tmp will fail when the expansion exceeds the kernel’s argument limit. The fix:

find . -name "*.tmp" -print0 | xargs -0 rm -f

find locates files and separates names with null bytes (-print0), and xargs reads those null-terminated strings (-0) to batch filenames into safe chunks, handling filenames with spaces correctly in the process.

For parallel execution, add the -P flag. To hash thousands of files for integrity verification across 8 concurrent processes:

find . -type f | xargs -P 8 md5sum

GNU Parallel

xargs is ubiquitous, but GNU Parallel is purpose-built for complex parallel workloads. Its key advantage: when running concurrent jobs via xargs, output from different processes can interleave chaotically. GNU Parallel buffers each job’s output until completion, keeping results contiguous and readable.

GNU Parallel also natively supports distributing jobs across multiple remote servers via SSH, turning a single workstation into a master node for an ad-hoc distributed processing cluster. As CI/CD and automation become critical metrics for engineering teams, these parallel processing tools represent a serious operational advantage.

Automating Pipelines via Shell Scripting

Individual commands become powerful when you stitch them into automated pipelines through shell scripting. A shell script is a text file containing commands, control flow, and variables, letting you dictate how data moves from point A to point B without manual intervention.

Building Resilient ETL Scripts

A Bash script starts with a shebang line (#!/bin/bash), telling the OS which interpreter to use. Within these scripts, you build complete ETL routines.

A practical example: launch a Linux VM on a cloud platform and build a pipeline that extracts financial metrics from an external API, performs local aggregations, and loads the output into a relational database. The script uses curl or wget to pull raw JSON or CSV data, then employs awk and sed to filter malformed records and calculate daily aggregates. Finally, it pipes transformed data directly into the database:

echo "COPY access_log FROM '/tmp/transformed_data.csv' DELIMITER ',' CSV;" 
  | psql --username=postgres --host=localhost

Because data pipelines often take hours to complete, you can’t risk tying them to an SSH session that might drop. Running nohup ./etl_pipeline.sh & detaches the process from your terminal entirely. It’ll keep running even if your connection dies, with output redirected to a log file.

Scheduling with Cron and At

Historically, scheduling meant cron. The cron daemon monitors system directories for timing instructions. Running crontab -e opens your scheduling table, where you define intervals using five fields (minute, hour, day of month, month, day of week) followed by the command:

00 21 * * * /path/to/script.sh > /path/to/output.log

That fires the ETL pipeline at 9:00 PM every day. For one-off jobs (a database backup in two hours, a temporary server reboot), the at command queues an operation without cluttering your crontab.

But cron has real limitations. It’s purely time-based: no awareness of upstream dependencies, no retry mechanisms for failed tasks, no centralized monitoring or alerting. While you can theoretically build a rudimentary DAG in Bash by chaining scripts with && and custom alerting, enterprise-scale platforms need dedicated orchestration.

Advanced Orchestration: Airflow and Prefect

The industry consensus is clear: production data platforms should migrate beyond cron toward orchestration frameworks that handle complex dependencies and dynamic resource allocation. Apache Airflow and Prefect are two of the most prominent, and both require deep Linux integration for production deployment.

Apache Airflow in Production

Deploying Airflow beyond local development is a serious architectural undertaking. You need a Web Server for the UI, a Scheduler to monitor and trigger DAGs, a DAG Processor to parse workflow definitions, an Executor for routing logic, a Metadata Database (typically PostgreSQL or MySQL) for state history, and horizontally scalable Workers for the actual computation.

Configuration on Linux leans heavily on environment variables. While Airflow defaults to airflow.cfg, best practice is to override dynamically. Airflow recognizes variables structured as AIRFLOW__{SECTION}__{KEY}. You can inject secure credentials without hardcoding:

export AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql://user:password@host/db

For additional flexibility, appending _cmd to a config key lets Airflow derive values from shell command execution.

Managing a high-volume cluster requires careful resource tuning. When DAG counts climb into the hundreds, you must allocate more CPU and memory to the Scheduler. Workers continuously polling the metadata database exhaust connection limits, so you’ll need connection pooling. PgBouncer is the standard choice. Logs from highly parallel Celery workers will eventually consume all local disk space, so production setups route execution logs to remote object storage (S3, GCS).

Security integrates tightly with Linux systems. On platforms like Google Cloud, server access and user permissions for Airflow nodes are governed by OS Login and Pluggable Authentication Modules (PAM). For Hadoop cluster authentication, the airflow kerberos command continuously refreshes security tokens from a Kerberos Keytab, typically isolated in a separate container that writes temporary tokens to a shared volume.

Prefect: A Modern Alternative

Airflow’s steep learning curve, complex DAG abstraction, and heavy infrastructure requirements are well-documented pain points. Local development alone demands at least 4 GB of RAM and multiple background services, which creates significant friction for rapid iteration.

Prefect was designed for data engineering and MLOps teams who want a frictionless developer experience. Instead of learning Airflow’s operational syntax, you write pure Python with @flow and @task decorators. This supports dynamic, runtime workflows using native Python loops and branching, something Airflow’s static DAG model struggles with.

Deploying Prefect on RHEL demonstrates the simpler architecture: a Server and Worker model with PostgreSQL as the backend, replacing Airflow’s complex web of schedulers and executors. To ensure these processes survive reboots and restart on failure, you create custom systemd service files that embed Prefect into the Linux initialization sequence.

Network Diagnostics in Distributed Data Architectures

Modern data platforms are inherently distributed, comprising separate database servers, cloud storage buckets, API endpoints, and worker nodes. When something fails, it’s often a network issue, not a bug in your Python or SQL. The ability to troubleshoot across the TCP/IP stack is a critical skill.

Layer 3 and Layer 4: Connectivity and Ports

Troubleshooting starts at Layer 3 (Network) to verify basic reachability. ping sends ICMP echo requests to check if a remote server is alive. If the host is unreachable or latency is spiking, traceroute (or the real-time mtr) maps the exact path packets take, isolating where connections drop or congest. The ip and route commands let you view and modify local routing tables.

But a live host doesn’t mean a specific service is reachable. At Layer 4 (Transport), you validate port availability. If an Airflow worker can’t reach a Redis broker on port 6379, use nc (Netcat) to test socket connectivity:

nc -zv 192.168.1.1 6379

This tells you immediately whether the port is open or a firewall rule is blocking traffic.

On the server side, verify that applications have bound to the correct network interface with ss (which has largely replaced netstat). Running ss -tuln gives a clean list of all listening TCP and UDP ports. If a port is unexpectedly occupied, lsof identifies which process holds the lock.

Layer 7: Application Diagnostics and Packet Capture

At the Application layer, failures often stem from DNS resolution issues, especially in cloud environments where IPs change frequently. nslookup and dig query DNS servers for A records, CNAME aliases, and MX records, confirming that endpoint URLs resolve correctly.

For API integration, curl is the industry standard debugging tool. Use it to simulate POST requests, inject authentication headers, and inspect response codes:

curl -I https://api.example.com

wget excels at reliably downloading large datasets over flaky connections, with built-in resume capabilities.

For the most stubborn issues (intermittent packet drops, malformed TCP handshakes, unencrypted data leaks), tcpdump captures raw network traffic in real time, letting you analyze exact byte structures on the wire.

Tool TCP/IP Layer Primary Use in Data Engineering
ping Layer 3 (Network) Server availability and round-trip latency
traceroute / mtr Layer 3 (Network) Mapping network hops and routing bottlenecks
nc / telnet Layer 4 (Transport) Testing whether specific database ports are reachable
ss / netstat Layer 4 (Transport) Confirming services (e.g., Kafka) are listening
dig / nslookup Layer 7 (Application) Diagnosing DNS resolution failures
curl Layer 7 (Application) Testing REST API endpoints and inspecting headers

Containerization and Immutable Execution Environments

Ensuring a pipeline runs identically on a developer’s laptop, a staging server, and a production cluster is paramount. Containerization, predominantly through Docker, achieves this reproducibility by leveraging Linux kernel features: control groups (cgroups) for resource limits and namespaces for process isolation.

Docker Fundamentals

Docker containers are stateless and ephemeral. Any data written to the container’s internal filesystem vanishes when it terminates. Databases like PostgreSQL need persistent storage, so you use Docker volumes to map internal directories to the host’s filesystem. Exposing a containerized database to external networks requires explicit port mapping (binding the container’s internal port to a host port).

Inside containers, managing isolated Python environments prevents dependency conflicts between libraries. Modern workflows use tools like uv to build reproducible Python environments directly within the container definition.

The Entrypoint Script

The Dockerfile‘s ENTRYPOINT and CMD directives control what happens when a container launches. In practice, containers rarely execute a single command cleanly on startup. They need initialization: waiting for database connections, running migrations, exporting environment variables.

This is handled by an entrypoint.sh script. The Dockerfile copies it in with COPY and grants execution permissions via RUN chmod +x /entrypoint.sh. Security best practice: keep this script immutable (no write permissions) to prevent runtime modification.

The script typically ends with exec python app.py "$@". The exec command is crucial: it replaces the current bash process with the target application, so the Python app becomes PID 1. This matters because PID 1 receives system signals (like SIGTERM) from the container orchestrator, enabling graceful shutdowns that prevent data corruption. The "$@" variable passes through any arguments from CMD or docker run.

One gotcha: when environment variables need to configure binary paths (like mssql-tools), declare them with ENV in the Dockerfile, not in .bashrc, which Docker doesn’t source during automated execution.

Knowledge Dissemination: The Technical Publishing Ecosystem

A data engineering project isn’t done when the pipeline runs. Documentation and knowledge sharing are part of the lifecycle. Because the field depends heavily on fast-evolving open-source tools, the community relies on technical blogging to document integration edge cases, architectural patterns, and debugging methodologies.

The primary platforms for developer-focused publishing are Hashnode, Dev.to, and Towards Data Science (TDS). Contributing to these builds your professional reputation while enriching the community’s collective knowledge base.

Markdown, YAML Front Matter, and Structure

Developer platforms overwhelmingly use Markdown, a lightweight markup language created by John Gruber and Aaron Swartz that’s readable in raw form and compiles cleanly to HTML. It handles formatting (bold, italics, lists, blockquotes, links) without cluttering your writing with HTML tags. Crucially, it supports syntax-highlighted code blocks, which are non-negotiable when demonstrating SQL queries or Python scripts.

Article metadata lives in YAML front matter, a block of key-value pairs at the top of the file enclosed by triple dashes (---):

Front Matter Key Purpose
title The H1 heading and HTML title tag (mandatory)
tags Keywords for indexing (e.g., linux, dataengineering, python)
canonical_url Tells search engines which URL is the original source (critical for cross-posting)
cover_image Header image URL for social media previews
published Boolean controlling whether the post is live or draft

Structure matters for accessibility. Follow a logical narrative: problem statement, technical solution with code examples, real-world conclusion. Headings must follow semantic hierarchy, so don’t skip from H2 to H6 or screen readers will choke. Provide alt-text for diagrams, go easy on emojis, and never use Unicode characters to create “fancy fonts.”

GitOps for Technical Blogs

A growing trend is treating documentation as code. Write articles locally in your IDE, commit the Markdown to GitHub, and use CI/CD pipelines to automate publishing.

Hashnode offers native GitHub integration. Install the Hashnode app on your repository, and when you push a Markdown file to the designated branch, Hashnode parses the front matter and publishes or updates accordingly, matching posts by slug.

For cross-posting to multiple platforms simultaneously (Dev.to, Hashnode, Medium), engineers build custom GitHub Actions. These scripts trigger on push, extract front matter metadata, and submit the article via each platform’s REST API using keys stored in GitHub Secrets. This eliminates the overhead of manual cross-posting entirely.

TDS Editorial Standards

While automated syndication maximizes reach, Towards Data Science enforces strict editorial guidelines. Authors submit drafts through a contributor form with a note on the topic’s timeliness. The editorial board reviews every submission for technical accuracy, logical progression, and clarity.

TDS rejects superficial listicles, basic tutorials without novel perspectives, and clickbait titles. Authors must demonstrate that a technical gap exists and that their solution is superior to existing approaches. Media usage is scrutinized: custom graphs (Python, R, D3.js) are preferred, external imagery must be properly attributed, and AI-generated images require verified commercial rights. Code must appear in proper code blocks, never screenshots. Only authors with verified, non-anonymous profiles are permitted to contribute.

This article was written to help data engineers, from early-career to mid-level, build a deeper appreciation for the Linux skills that underpin every modern data platform. These fundamentals are what separate operators from architects. The article was submitted in fulfilment of a LuxDevHQ Cohort 7 DataEngieering assignment ©adev3loper

pgwd in Production: From Alerts to Runbook

Anonymized pgwd Slack alerts timeline

This is a production-focused follow-up to my original post: pgwd: A Watchdog for Your PostgreSQL Connections. In this one, I show what pgwd looked like in action, how we responded, and what we changed in a controlled way.

When PostgreSQL connection pressure builds up, the real problem is not just crossing max_connections; it is crossing it without operational context.

That is where pgwd helped us most: not as “just another alert sender,” but as a signal-to-action layer for on-call decisions.

What happened (anonymized timeline)

In one of our production environments, we saw a fast escalation pattern:

  • attention (75%)
  • then alert (85%)
  • then repeated danger (95%+)

All within a relatively short window.

The key signal from Slack was not only the threshold level, but also the breakdown:

  • total
  • active
  • idle
  • max_connections
  • plus cluster, database, namespace, and client

That context let us decide quickly whether we were seeing true workload pressure, connection churn, or an idle-heavy pattern that still threatened capacity.

Anonymized pgwd Slack alerts timeline

Why threshold levels matter in production

A 3-tier model (75/85/95) maps well to real operations:

  • Attention (75%): observe trend, prepare people
  • Alert (85%): start mitigation planning
  • Danger (95%): execute containment now

This prevented us from waiting for FATAL: sorry, too many clients already as the first real signal.

The runbook we used (manual-first)

For this rollout, we intentionally chose controlled, operator-present execution.

No unattended automation for critical steps yet.

1) Attention (>=75%)

  • Confirm trend across intervals (not just one spike)
  • Check active vs idle ratio
  • Verify affected DB scope (single DB vs multiple)
  • Open an observation incident thread

2) Alert (>=85%)

  • Engage app + platform on-call
  • Correlate with scheduled jobs, batch windows, and maintenance events
  • Reduce non-critical pressure if possible
  • Prepare containment action

3) Danger (>=95%)

  • Execute mitigation immediately (controlled maintenance/throttling based on internal SOP)
  • Prioritize availability restoration
  • Capture timestamps for post-incident learning

What we are changing now: max_connections to 3192

Based on this run, one concrete action in our runbook is:

  • Increase PostgreSQL max_connections from 2048 to 3192
  • Apply the change in a controlled session with operators present
  • Monitor closely after the change

This is not “increase and forget.”

It is “increase, observe, validate, and adjust.”

Configuration update with max_connections and resource values

Important guardrail: do not solve pressure by over-sizing blindly

Raising connection limits without infrastructure awareness can create a different failure mode:

  • more connections -> more backend memory/CPU pressure
  • more pressure -> noisy performance and unstable pods/nodes
  • teams then over-allocate resources reactively

So our runbook explicitly includes this guardrail:

  • Track infrastructure headroom (CPU/memory) after increasing limits
  • Validate DB and app behavior under the new ceiling
  • Avoid resource over-sizing unless data supports it

Minimal commands (hybrid style)

# Basic daemon mode
export PGWD_DB_URL="postgres://..."
export PGWD_SLACK_WEBHOOK="https://hooks.slack.com/..."
export PGWD_INTERVAL=60
pgwd

# Verify notifier delivery before/after changes
pgwd -force-notification

# Optional: run against Postgres service in Kubernetes
pgwd -kube-postgres <namespace>/svc/postgres 
     -db-url "postgres://user:...@localhost:5432/db"

Post-change verification checklist (24h / 72h)

After increasing to 3192, we track:

  • Alert frequency by level (attention / alert / danger)
  • active / idle behavior by database
  • Peak total connections vs new headroom
  • App error rates and latency around peak windows
  • Infrastructure utilization trend (not just point-in-time)

Success criteria:

  • No sustained danger periods
  • Fewer repeated escalations
  • Stable app behavior
  • No unjustified resource inflation

Lessons learned

  1. pgwd is most valuable when tied to a runbook, not only to notifications.
  2. Alert levels should map to explicit operator actions.
  3. Controlled, manual-first execution is safer for critical production changes.
  4. Increasing max_connections can be right, if paired with disciplined capacity monitoring.

Community note

If you want a complementary intro in French (installation + quick setup), this community write-up is also useful:

If you run PostgreSQL in production, I would love to hear your threshold strategy and runbook design.

Disclosure: This post was drafted with AI assistance and reviewed by the author.

I Built an Uptime Monitoring SaaS in a Weekend — Here’s What I Learned

The Setup

I was paying $15/month for Uptime Robot.

I used maybe 30% of it. Most days I just needed to know: is my site up?

Friday night I thought: I could build this myself.

Saturday night I realized: I could build this in a weekend.

By Monday morning it was live.

Now I have 50 users and 3 paying customers. Here’s what actually happened.

Friday: The Problem Gets Real

You know when you pay for something you don’t fully use, and you can’t quite justify canceling it? That was me with Uptime Robot.

$15/month × 12 months = $180/year for monitoring I could describe as: “tell me if my site goes down.”

I wasn’t using:

  • Advanced analytics
  • The 17 different integration options
  • Custom headers on requests
  • Response time tracking
  • Detailed historical reports

I just wanted: HTTP request every 30 seconds, alert when it fails.

So I sketched out what “simple” actually means:

Core features:

  • Monitor HTTP endpoints
  • 30-second check interval (fast enough for real alerts, slow enough to not hammer servers)
  • Webhook alerts (route to whatever I want)
  • Email alerts (backup option)
  • Simple dashboard (show status and last check time)

Tech stack:

  • Node.js + Express (I know it, builds fast)
  • SQLite (no external database, backup = copy a file)
  • DigitalOcean $5/month droplet (already had one)
  • Stripe for payments (industry standard, boring is good)

What I explicitly did NOT build:

  • Synthetic user journeys (too complex, different problem)
  • Advanced dashboards (nobody cares)
  • Integrations with every platform (webhooks handle 90% of use cases)
  • TLS certificate monitoring (separate thing)
  • Custom alert routing (webhooks are the answer)

This list of “what to NOT build” was more important than the “what to build” list.

Saturday: The Core Engine

Saturday I built the monitoring engine:

// Simplified version of the core check logic
async function performCheck(monitor) {
  const startTime = Date.now();

  try {
    const response = await fetch(monitor.url, {
      timeout: 10000,
      method: 'HEAD' // Use HEAD, not GET (faster)
    });

    const duration = Date.now() - startTime;

    if (response.status >= 200 && response.status < 300) {
      return {
        status: 'up',
        statusCode: response.status,
        duration: duration,
        timestamp: new Date()
      };
    } else {
      return {
        status: 'down',
        statusCode: response.status,
        reason: 'HTTP error',
        duration: duration,
        timestamp: new Date()
      };
    }
  } catch (error) {
    return {
      status: 'down',
      reason: error.message,
      duration: Date.now() - startTime,
      timestamp: new Date()
    };
  }
}

Most of the day was actually spent on edge cases:

Problem 1: Flaky networks
If a DNS lookup times out, is that a real alert or just a blip? I implemented retry logic:

  • First timeout: wait 10 seconds, try again
  • Second timeout: alert
  • This reduced false positives from 10% to <1%

Problem 2: SSL certificate issues
A site can have an invalid cert but still serve content. Do we alert? I added logging for this:

  • Invalid cert = alert with reason “SSL cert issue”
  • Response still comes through = not an outage, but a problem

Problem 3: Hanging connections
Some servers hang indefinitely on connect. I added timeout logic:

  • 10-second timeout on each check
  • If it hangs, mark as down and move on

Problem 4: Load spreading
If I check 1000 sites every 30 seconds, that’s 33 requests per second. Spread over 30 seconds, that’s 1 req/sec. But concurrently? I learned about connection pooling fast.

Solution: use HTTP/2 keep-alive, limit concurrent connections, queue checks.

By 11pm Saturday night, the engine worked. I could add a URL and get alerted when it went down.

Sunday: The Boring Stuff

Sunday was database schema, UI, and Stripe integration.

Database (SQLite):

CREATE TABLE monitors (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  name VARCHAR(255),
  url VARCHAR(2048),
  check_interval INTEGER DEFAULT 30,
  alert_method VARCHAR(50),
  created_at TIMESTAMP,
  updated_at TIMESTAMP
);

CREATE TABLE checks (
  id UUID PRIMARY KEY,
  monitor_id UUID NOT NULL,
  status VARCHAR(10),
  status_code INTEGER,
  response_time INTEGER,
  error_message TEXT,
  checked_at TIMESTAMP,
  FOREIGN KEY (monitor_id) REFERENCES monitors(id)
);

CREATE TABLE alerts (
  id UUID PRIMARY KEY,
  monitor_id UUID NOT NULL,
  previous_status VARCHAR(10),
  new_status VARCHAR(10),
  triggered_at TIMESTAMP,
  delivered_at TIMESTAMP
);

Simple schema. No normalization beyond basic stuff. Easy to query.

UI:

The dashboard was ruthlessly simple:

  • List of monitors
  • Status for each (up or down)
  • Last check time
  • Uptime percentage (last 24 hours)
  • Button to add a monitor

No graph. No drill-down. No customization.

People hated the simplicity at first. Then they realized they could find what they needed in 3 seconds instead of 3 minutes.

Stripe Integration:

Took longer than expected. Their API is comprehensive but has a learning curve.

Key parts:

  • Webhook handling for payment events (customer.subscription.created, charge.succeeded, etc)
  • Free tier switching logic (auto-downgrade if payment fails)
  • Webhook validation (verify it came from Stripe, not someone faking it)

By 8pm Sunday: everything worked.

Monday: Launch Day

Deployed to DigitalOcean. Set up HTTPS (Let’s Encrypt, free). Wrote landing page copy.

Launched on:

  1. IndieHackers (~20 signups)
  2. Dev.to (~15 signups)
  3. Reddit comments in r/webdev, r/SaaS (~8 signups)
  4. Twitter thread (~5 signups)

Total: ~50 signups in 24 hours.

What’s Working

1. Ruthless scope

Every feature request got filtered through: “Is this essential?”

90% were “no.”

The 10% that were “yes” were: reliability, simplicity, price.

2. Honest pricing

$12/month because that’s what I’d pay. Not:

  • $12.99 (avoiding the number 9)
  • $19/month (higher price = more features in people’s heads)
  • Free trial then paywall

Just: $0 for 10 monitors, $12/month for 25 monitors.

3. Free tier that works

10 monitors forever. No credit card. No time limit. No restrictions that disappear.

This converts because people actually use it, find it useful, then pay.

4. Personal support

Responded personally to every signup email, every question. Takes time but builds trust.

People don’t expect founders to read their emails. They’re shocked when you do.

5. Building in public

Posted about building it on Twitter as it happened. People followed along. Built community before launch.

What’s Not Working

1. Discovery is brutal

Getting 50 users is easy. Getting 500 is hard. Getting 5000 is really hard.

I don’t have a marketing engine yet. Just me posting on niche communities.

2. Feature requests pile up fast

Everyone wants Slack integration. Some want SMS. Some want custom headers.

I can build maybe 1 feature per week. Feedback comes in at 3 per day.

This is a good problem (means people use it) but also stressful.

3. Support takes time

Even simple product = people have questions.

“How do I monitor FTP?” (Can’t, not HTTP)
“Can I check every 5 seconds?” (No, I chose 30s for good reasons)
“Will you integrate with Datadog?” (Not a priority)

Each question is an email I read and respond to.

4. Pricing might be wrong

$36/month revenue, $8/month costs = technically profitable.

But is $12/month the right price? Should it be $19? $5?

I don’t know yet. I’ll know when growth slows.

What I Learned

Technical

1. Monitoring is harder than it looks

HTTP checks seem simple: send request, check response. The edges are where complexity hides.

Timeouts, retries, false positives, concurrency limits. Hours spent on these.

2. SQLite is underrated

I was worried about scaling. Realized: 1000 monitors with 30-second checks = 2000 inserts/minute = no real load.

SQLite handles this easily on a $5 box.

Lesson: don’t over-engineer for future scale you don’t have.

3. Keep infrastructure simple

Single DigitalOcean droplet. Single database. No message queue (yet). No microservices.

If something breaks, I can SSH in and see exactly what’s wrong.

Complexity is the enemy of reliability.

Business

1. Building > Planning

I could have spent 3 months planning. Instead I built for 20 hours and learned more than I would in 3 months of planning.

Users tell you what matters. Plans don’t.

2. Bootstrapped is freedom

No investor pressure to grow fast. No need for a VC exit.

If this gets to $5k/month, I’m happy. If it stays at $36/month, I learned a ton.

This takes pressure off and lets me make good long-term decisions.

3. Support is a feature

Replying to every email personally is slow and doesn’t scale.

But it’s the moat right now. People feel heard. That’s rare with SaaS.

4. Honest language sells

“Simple uptime monitoring” beats “revolutionary monitoring platform”

“$0 free or $12/month” beats “free trial, then $29/month”

People respect transparency.

Marketing

1. Content >> Ads

Every signup came from content I made (Twitter thread, Dev.to, Reddit comments, IndieHackers post).

Zero dollar spent on ads. Zero results from ads.

2. Being early is useful

Launching on IndieHackers while bootstrapping was interesting to people.

Launching on PH/HN soon will be even more interesting (because it’s real users, real revenue).

3. Niche communities > broad reach

50 signups from targeted communities (r/webdev, r/SaaS) is worth more than 500 from random places.

These communities have actual standards and actual users.

Numbers (Full Transparency)

Launch week:

  • 50 signups
  • 5 paid customers (3 stuck around)
  • $36 MRR
  • 400 site visitors
  • 0 dollar spent on marketing

Infrastructure:

  • $5/month: DigitalOcean droplet (1GB, 1 CPU)
  • $10/year: domain
  • ~$0.70/month: Stripe processing fees
  • Total: $8/month

Time invested:

  • 20 hours: building
  • 10 hours: thinking about it first
  • 5 hours: writing copy and posting
  • Total: 35 hours

Current (1 week later):

  • 50 total signups
  • 3 active paid customers
  • 120+ sites being monitored
  • 99.8% uptime
  • $36 MRR

What’s Next

This week:

  • Talk to paying customers about what matters
  • Fix bugs as they appear
  • Write better documentation
  • Respond to all feedback

Next month:

  • Slack integration (everyone asks for it)
  • Better reporting (time-based uptime stats)
  • API access (for advanced users)

In 3 months:

  • Evaluate if this is a real business or a hobby
  • Decide how much time to invest

In 1 year:

  • Either: $5k+ MRR (make it bigger)
  • Or: Keep as small profitable product
  • Or: Realize it doesn’t work and move on

All three are acceptable outcomes.

Takeaway

You don’t need months of planning to build and launch something real.

You need:

  1. Problem you actually have
  2. Scope that fits in a weekend
  3. Willingness to ship rough and learn from users

I could have:

  • Spent 3 months perfecting the UI
  • Built 20 features nobody needs
  • Over-engineered the infrastructure
  • Procrastinated while “planning”

Instead I shipped in 3 days.

That’s the win.

The specific product (uptime monitoring) is less important than the fact that I went from idea to production with real users in 72 hours.

Try It

If you’re monitoring websites and tired of your current setup, check out PingSentry: https://pingsentry.app

Free tier is actually useful (10 monitors, forever, no credit card).

Paid tier is $12/month (25 monitors) or $39/month (unlimited) if you want more.

Questions I Know You’ll Ask

Q: Will you keep building this?

A: If there’s enough demand, yes. I’m bootstrapped, so it’s long-term thinking. If I get to 100-200 paying customers, it becomes a real time investment.

Q: Why not open source it?

A: Might do this eventually. For now, I want to own the customer relationship and understand the business.

Q: How long until you have 10 employees?

A: If it gets there, great. But I’m optimizing for sustainability, not growth. A profitable 1-person business is better than a failing 10-person team.

Q: Isn’t this saturated?

A: Yes. Uptime monitoring is commodity. But there’s room for the simple, boring alternative to expensive platforms. That’s my bet.

Q: What if Uptime Robot launches a $5/month tier?

A: I’m probably in trouble. But they’re not optimizing for simple + cheap. So I have time.

Thanks

To everyone who signed up, tried it, and sent feedback. You made this real.

To the dev community (Reddit, HN, Twitter, Dev.to) for amplifying my posts.

To the bootstrapped SaaS community for showing that this path exists.

If you’re building something: ship it. Stop planning. Start shipping.

That’s it. That’s the advice.

Connect