Observability in 2026: Distributed Tracing Replaced Logs, and OpenTelemetry Won

Observability in 2026: Distributed Tracing Replaced Logs, and OpenTelemetry Won

The observability landscape in 2026 looks nothing like 2020. Logs are now secondary. Traces are primary. And OpenTelemetry (OTel) won the instrumentation wars so decisively that the term “vendor-neutral observability” became a redundant phrase. Here’s what changed.

The Old Model: Logs as the Source of Truth

In 2020, debugging meant logs:

# The old way
logger.info(f"Processing order {order_id} for user {user_id}")
logger.info(f"Payment processing for ${amount}")
logger.error(f"Payment failed: {error_code}")

# Debugging a production issue:
# 1. Find the right log lines across 50 service logs
# 2. Correlate timestamps across machines (which may not be synced)
# 3. Reconstruct what happened from thousands of log lines
# 4. Hope the relevant lines weren't filtered out by your logging library

This model broke down with microservices. A single user request touches 20 services. Correlating logs across 20 services at different timestamps is archaeology, not engineering.

The New Model: Traces as Primary

# The new way: OpenTelemetry
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# Set up the tracer
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter())
)
tracer = trace.get_tracer(__name__)

# Instrument your code
def process_order(order_id: str, user_id: str, amount: float):
    with tracer.start_as_current_span("process_order") as span:
        span.set_attribute("order.id", order_id)
        span.set_attribute("user.id", user_id)
        span.set_attribute("order.amount", amount)

        with tracer.start_as_current_span("validate_order"):
            # validation logic
            pass

        with tracer.start_as_current_span("process_payment") as payment_span:
            payment_span.set_attribute("payment.method", "stripe")
            result = stripe.charge(amount)
            payment_span.set_attribute("payment.status", result.status)

            with tracer.start_as_current_span("send_confirmation"):
                send_email(user_id, result)

Now when you look at your observability platform, you see:

process_order (2.3s)
├── validate_order (0.1s)
├── process_payment (2.1s)
│   ├── stripe.charge (1.8s)
│   └── send_confirmation (0.3s)

One trace. Every service. Complete latency breakdown. No log archaeology.

OpenTelemetry: The Standard That Won

OpenTelemetry is now the universal instrumentation standard. Every major observability platform supports it:

  • Datadog ✓
  • Honeycomb ✓
  • Grafana Tempo ✓
  • Jaeger ✓
  • New Relic ✓
  • AWS X-Ray ✓
  • Google Cloud Trace ✓
# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024

  memory_limiter:
    check_interval: 1s
    limit_mib: 4000

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: false

  datadog:
    api:
      key: ${DATADOG_API_KEY}

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/tempo, datadog]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/tempo, datadog]

Auto-Instrumentation: Zero-Code Observability

The biggest win in 2026: auto-instrumentation. You get distributed tracing without changing your code.

Python Auto-Instrumentation

# Install the agent
pip install opentelemetry-instrumentation-all

# Run your app with auto-instrumentation
opentelemetry-instrument python your_app.py

This automatically instruments:

  • HTTP requests (Flask, FastAPI, Django, aiohttp)
  • Database calls (psycopg2, SQLAlchemy, asyncpg)
  • Redis, Memcached, Kafka
  • gRPC, HTTPX

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-service
spec:
  template:
    spec:
      containers:
      - name: my-service
        image: my-service:latest
        env:
        - name: OTEL_SERVICE_NAME
          value: "my-service"
        - name: OTEL_EXPORTER_OTLP_ENDPOINT
          value: "http://otel-collector:4317"
        - name: OTEL_RESOURCE_ATTRIBUTES
          value: "deployment.environment=production"
        - name: OTEL_PROPAGATORS
          value: "tracecontext,baggage"
        - name: OTEL_TRACES_SAMPLER
          value: "parentbased_traceidratio"
        - name: OTEL_TRACES_SAMPLER_ARG
          value: "0.1"  # Sample 10% of traces

The Three Pillars: Traces, Metrics, Logs

Metrics: SLOs and Alerts

from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

# Set up metrics
metric_reader = PeriodicExportingMetricReader(
    OTLPMetricExporter(), export_interval_millis=30000
)
metrics.set_meter_provider(MeterProvider(metric_readers=[metric_reader]))
meter = metrics.get_meter(__name__)

# Create metrics
order_counter = meter.create_counter(
    "orders_processed",
    description="Number of orders processed",
    unit="1"
)

payment_duration = meter.create_histogram(
    "payment_duration",
    description="Payment processing duration",
    unit="ms"
)

error_counter = meter.create_counter(
    "payment_errors",
    description="Number of payment errors"
)

# Use them
def process_payment(amount: float):
    with tracer.start_as_current_span("process_payment"):
        try:
            start = time.time()
            result = stripe.charge(amount)
            payment_duration.record((time.time() - start) * 1000)
            order_counter.add(1, {"status": "success"})
            return result
        except Exception as e:
            error_counter.add(1, {"error": type(e).__name__})
            raise

Structured Logs (Still Useful, But Secondary)

import structlog

structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer()
    ]
)

log = structlog.get_logger()

# Structured logs now include trace context automatically
log.info("payment_processed",
    order_id="12345",
    amount=99.99,
    # These are automatically injected from the current trace:
    # trace_id, span_id, trace_flags
)

Sampling Strategies: The Key to Cost Control

Traces are verbose. You can’t afford to trace 100% of requests at scale. Sampling is essential.

Head-Based Sampling (At Trace Start)

from opentelemetry.sdk.trace.samplers import TraceIdRatioBased

# Sample 10% of all traces
sampler = TraceIdRatioBased(0.1)

provider = TracerProvider(sampler=sampler)

Tail-Based Sampling (After Trace Completes)

Tail-based sampling captures errors and slow requests while sampling most fast successful requests. This requires your observability platform to support it.

# Grafana Tempo tail-based sampling
overrides:
  "service.namespace:payments":
    processors:
      - type: latency
        threshold_ms: 1000  # Always keep traces > 1s
      - type: status_code
        status_codes:
          - ERROR  # Always keep errors
      - type: trace_state
        key: environment
        values: [production]  # Always keep production
      - type: probabilistic
        sampling_percentage: 5  # Sample 5% of the rest

Service Level Objectives (SLOs) in Your Observability Platform

# Grafana Tempo + SLO example
groups:
  - name: orders-slo
    rules:
    - alert: OrderLatencyHigh
      expr: |
        histogram_quantile(0.95,
          sum(rate(tracetest_s spans{ service="order-service" }[5m]))
          by (le)
        ) > 1000
      for: 5m
      labels:
        severity: critical
      annotations:
        summary: "Order processing P95 latency > 1s"
        runbook_url: "https://wiki.example.com/runbooks/order-latency"

    - alert: PaymentErrorRateHigh
      expr: |
        sum(rate(tracetest_spans{ 
          service="payment-service",
          span.kind="server",
          otel.status_code="ERROR"
        }[5m])) /
        sum(rate(tracetest_spans{ 
          service="payment-service",
          span.kind="server"
        }[5m])) > 0.01
      for: 2m
      labels:
        severity: critical

The Debugging Workflow in 2026

Before OTel

  1. Customer reports slow checkout
  2. Scrape logs from 20 services
  3. Reconstruct timeline from log timestamps
  4. Hope you can reproduce the issue
  5. Average time to resolution: 4+ hours

After OTel

  1. Customer reports slow checkout
  2. Open Grafana, search by user ID
  3. See the complete trace: 1.8s in Stripe, 0.5s in email
  4. Drill into the Stripe span: connection pool exhausted
  5. Average time to resolution: 15 minutes

The Observability Stack in 2026

Instrumentation Layer:
├── OpenTelemetry SDK (auto-instrumentation)
├── Language-specific agents (Python, Node, Go, Java, Rust)
└── Custom spans for business logic

Collection Layer:
├── OpenTelemetry Collector (otelcol)
├── Grafana Alloy (successor to Grafana Agent)
└── Vector (for logs and metrics)

Storage & Query Layer:
├── Grafana Tempo (traces) — S3/MinIO backend
├── Prometheus + Thanos (metrics)
├── Loki (logs)
└── Datadog/New Relic/Honeycomb (if you prefer managed)

Visualization:
└── Grafana (universal) or platform-native UIs

Alerting:
└── Grafana Alerting or platform-native

The Migration Path

Step 1: Deploy OTel Collector

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector:0.96.0
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
      - "8888:8888"   # Prometheus metrics

Step 2: Instrument One Service

# Python
pip install opentelemetry-api 
            opentelemetry-sdk 
            opentelemetry-exporter-otlp 
            opentelemetry-instrumentation-flask

# Run with auto-instrumentation
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 
OTEL_SERVICE_NAME=my-service 
opentelemetry-instrument python app.py

Step 3: Verify in Grafana

Open Grafana → Explore → Select Tempo datasource → Search for your service name. If you see spans, instrumentation is working.

Step 4: Incremental Rollout

Add instrumentation service by service. Each service you add makes debugging easier across all previously-instrumented services.

The Bottom Line

OpenTelemetry won because it solved the real problem: instrument once, query anywhere, vendor-neutral forever. The cost is upfront instrumentation complexity, but the payoff is complete observability without vendor lock-in.

If you’re still running on logs alone, you’re debugging in 2020. Migrate to traces. Your future self (and your on-call rotations) will thank you.

Running OpenTelemetry in production? What’s your stack and biggest win?

Pause menus, asset deduplication, end-screen nodes (Devlog #2)

Hey again. I want to walk through where the canvas has gotten since then, because a lot of the work has been the unglamorous middle of a tool: the parts that don’t really sell anything but that you discover you can’t ship without. Pause menus. Asset cleanup. Editor performance. We added one big new node type in the last few days too, but most of the actual hours went into the boring half.

Here’s roughly where things landed.

The first thing we built was a pause menu. Until now ChoiceStory Studio had one editable surface, the main menu,and adding pause was the first time we had to decide what a sub-surface actually is. We landed on this: the pause menu is not its own node, it’s a second authoring face of the main menu, reached through a tab in the editor. Same right panel, same canvas tile, but at runtime it mounts as an overlay over the live scene when the player hits Escape. The design surface borrows the same drag and resize handles the main menu has, so the muscle memory carries over.

Then a feature we didn’t plan but turned out to be necessary: asset deduplication. When you import the same video twice, ChoiceStory now fingerprints both files and offers to share the existing entry instead of writing a duplicate.A test project we’d been kicking around had 106 files on disk; after dedup and orphan cleanup it sat at 4 referenced files. The fix had to go deeper than the dedup dialog. Every reference in a project (backgrounds, button images,fonts, the lot) now resolves through a registry that is the single source of truth, so “delete unused files” actually knows what’s used and what isn’t.

Around the same time we added a performance settings modal in the File menu. Three toggles: follow active node on selection, recompute canvas viewport on resize, node glow animations. Two presets: high-spec PC and low-spec PC. The reason is that our target audience is creators on consumer-grade machines, not workstations, and some of the canvas animations were burning more cycles than the canvas itself. The toggle group is a stopgap; before the alpha we want a real Performance tab. The internal rule that came out of this work: any new feature that adds visible CPU or GPU load gets a toggle on the way in, never as a retrofit.

Next came a side-quest we called the parity pass. The main menu had affordances the pause menu didn’t: per-element drag, per-element resize, draggable backgrounds with aspect-locked corner handles. So we pulled the drag and resize pipeline up out of main-menu/ (it never actually held any main-menu-specific knowledge in it) and pointed both surfaces at the same module. Future surfaces (choice overlays, options) inherit those affordances by reusing the same code, which was the whole point of the lift. We also closed a long-standing schema bug while we were in there. If you flipped a background between colour, image, and video schema keeps separate slots per variant and only the active one paints.

The newest addition is the end-screen node. It started life as another sub-surface (same shape as pause) and stayed that way for about ten minutes of planning before we changed our mind. End screens are different. A story can have several of them (good ending, bad ending, secret ending, joke ending) and each one needs its own configuration,so they had to be canvas-creatable nodes you can drop, configure, and connect to. Right-click on the canvas adds one.Right-click on the node copies it; right-click on empty canvas pastes a deep clone at the cursor. The config block for each instance is full: background, title, multi-line outro, music, font, a list of one to five buttons (per-button text, optional image, an appearance delay in milliseconds, and an action: return to main menu, restart, or quit). Optional auto-advance after a configurable delay. The buttons fade in one by one on a timeline the author writes. The runtime swap was the last piece. When a player choice routes to an end-screen node, the playback layer mounts the configured surface instead of trying to play it as a video.

Now the honest part.

A lot of the work above is what made it through. Plenty of stuff didn’t. The pause-menu post-closure pass shipped eleven follow-up commits across a single session, and three of them got reverted by commit ten, because every fix had downstream consequences we hadn’t mapped. Sub-state in React is the easy half; the hard half is the graph of “if I change this, what else re-renders, in what order, with which stale value.” That session ended with a rule we have actually been following since: for every bug fix, write down the side-effect surface before writing the patch, and a list of files you deliberately did not touch. The follow-up count has dropped sharply since.

The CSS side gave us a similar lesson. We spent a first iteration on container queries to scale the pause menu inside variable Player-slot sizes. They worked, mostly, until they didn’t on a small slot. We threw it out and used the recipe the main menu already used (viewport-relative clamps + flex with min-height: 0 + overflow: hidden) and it just behaved. The takeaway: if the older pattern already passes every test, the newer pattern needs a reason that survives ten minutes of “but does the existing one already do this?”

What’s next is the choice overlay editor, the panel where the buttons that appear over a video (or an image) get designed. Same surface contract, same shared drag and resize infrastructure. We expect it to come together faster than end-screen because most of the substrate is in place now.

If you’ve read this far, thanks. If you’ve built something with branching video, or want to, the same line as last time stands. Come tell us what you’d want from a tool like this. Reply here, find us on X as @ChoiceStoryDev, email,whatever’s easiest. The harsh stuff especially.

More soon…

From Models to Meaning: How Building NeuroSense AI with Gemma 4 Changed My View of Local AI

Introduction

As a computer science student, I have spent a lot of time experimenting with AI systems and reading about what they can do. But one thing kept bothering me.

Many AI systems today are powerful, but they often feel distant.

You send information to the cloud.

You wait for a response.

You get an answer.

And the cycle repeats.

That works for many applications, but I started asking myself a different question:

What happens when AI becomes more personal, more private, and closer to people?

That question became especially important while thinking about mental well-being applications.

People share deeply personal thoughts:

  • stress before examinations
  • anxiety
  • emotional struggles
  • moments of uncertainty

For systems handling sensitive conversations, privacy is not simply a feature.

It becomes part of the design itself.

While exploring this idea, I started working on a concept called NeuroSense AI, a privacy-focused stress insight assistant powered by Gemma 4.

And while building it, I realized I wasn’t only learning about a model.

I was learning about a different way to think about AI.

The Idea Behind NeuroSense AI

The purpose of NeuroSense AI is simple:

Allow users to express their thoughts naturally while receiving intelligent emotional insights and supportive guidance.

The system aims to:

  • understand conversational tone
  • identify emotional patterns
  • estimate stress indicators
  • provide helpful recommendations
  • preserve user privacy as much as possible

A user might type:

“I have exams tomorrow and I feel overwhelmed.”

Instead of generating only a generic answer, the system can attempt to understand emotional context and respond meaningfully.

That made me think about something important:

AI should not only process words.

Sometimes it should understand human context too.

Why I Chose Gemma 4

When building NeuroSense AI, choosing a model wasn’t only about selecting the largest model available.

I wanted the model choice to solve a specific problem.

Gemma 4 stood out because of several reasons.

Local Possibilities

Sensitive conversations are different from ordinary prompts.

Mental well-being applications often involve personal information.

Running AI closer to users can potentially improve:

  • privacy
  • accessibility
  • control

Multiple Model Sizes

Gemma 4 provides different model options depending on hardware requirements.

Smaller models can support:

  • mobile environments
  • edge systems
  • lower-resource devices

Larger variants can support:

  • reasoning-heavy workflows
  • larger conversations
  • advanced applications

This flexibility makes development more interesting.

Long Context Window

Gemma 4 introduces a 128K context window.

Initially I saw this as a technical specification.

Then I thought about practical use cases.

Long context can help with:

  • longer conversations
  • research assistance
  • large documents
  • session memory
  • understanding broader context

Context changes how AI feels.

Instead of isolated responses, interactions begin to feel more continuous.

What Building Taught Me

The most interesting lesson wasn’t technical.

It was human.

When people interact with AI systems, they are not always looking for perfect predictions.

Sometimes they want:

  • understanding
  • support
  • privacy
  • trust

As developers, we often focus on:

  • parameters
  • benchmarks
  • speed
  • performance

But building NeuroSense AI reminded me that behind every prompt is usually a person.

And that person matters more than the numbers.

Why Local AI Matters

I believe local AI changes several things:

Privacy

Sensitive information does not always need to leave the user’s environment.

Accessibility

Students and independent developers can build systems without requiring massive infrastructure.

Lower Latency

Less dependency on remote services can improve responsiveness.

Offline Intelligence

Useful AI experiences can exist even with limited internet access.

Final Thoughts

Before exploring Gemma 4, I mostly thought about AI in terms of capability.

Now I think more about responsibility.

Powerful models are important.

But meaningful applications are even more important.

The future of AI may not simply be larger models.

It may be smarter systems that work closer to people and solve real problems.

Building NeuroSense AI made me realize something:

The question is no longer:

“Can we build intelligent systems?”

The question is:

“How can we build systems that understand people better?”

I would love to hear what kinds of human-centered AI experiences others would build.

Docker Alternatives in 2026: Podman, Lima, containerd, and the End of the Docker Monopoly

Docker Alternatives in 2026: Podman, Lima, containerd, and the End of the Docker Monopoly

Docker is no longer the only game in town. Podman matured dramatically, Lima made macOS containers practical, and containerd became the standard for production. In 2026, choosing a container runtime requires actually understanding your options. Here’s the honest breakdown.

The Docker Monopoly Is Over

Docker’s dominance was always about convenience, not technical superiority. The Docker daemon (dockerd) that runs as root, the proprietary CLI, and the closed ecosystem were always compromises. In 2026, the alternatives are production-ready for most use cases.

Podman: Docker-Compatible Without the Daemon

Podman became the default for security-conscious teams. No daemon means no root privileges, no daemon crashes, and better systemd integration.

Installation and Setup

# macOS
brew install podman
podman machine init
podman machine start

# Linux (Fedora/RHEL already has it)
sudo dnf install podman

# Verify
podman run --rm docker.io/library/alpine echo "Podman works!"

The Daemon-Free Architecture

# Docker: daemon-based (root privilege required)
# dockerd runs as root, all containers are children of root process

# Podman: daemon-free (user privilege)
# Each container runs as a child of your user process
# No root daemon = no root vulnerabilities

# Podman 5.x (2026) features:
# - Rootless containers by default
# - Pods (like Kubernetes pods)
# - cgroups v2 fully supported
# - Kubernetes YAML support (podman generate kube)
# - Docker-compatible CLI (alias docker=podman works)

Pods: Kubernetes-Style Grouping

# Create a pod with multiple containers
podman pod create --name myapp-pod 
  -p 8080:80 
  -p 5432:5432

# Add containers to the pod
podman run -d --pod myapp-pod --name nginx nginx:alpine
podman run -d --pod myapp-pod --name postgres postgres:16

# All containers in the pod share the network namespace
# Access localhost:80 → nginx
# Access localhost:5432 → postgres

# Generate Kubernetes YAML from the pod
podman generate kube myapp-pod > myapp.yaml
# Now deploy to Kubernetes with zero changes

Rootless Containers

# Podman runs as your user, not root
$ podman run --rm alpine id
uid=0(root) gid=0(root)

# Wait, root? This is actually correct inside the container
# The container's root is mapped to an unprivileged user on the host

# Check on the host
podman unshare cat /proc/self/uid_map
# Shows: 0 1000 1 (container root = host user 1000)

# This means even if a container escapes, it has limited host access

Dockerfile Compatibility

# Podman uses Dockerfiles directly
# Just point to your existing Dockerfiles

podman build -t myapp:latest .
podman push myapp:latest docker://registry.example.com/myapp:latest
# or
podman push myapp:latest containers://registry.example.com/myapp:latest

# The container registries support both:
# docker:// (Docker registry protocol)
# containers:// (OCI registry protocol)

Lima: macOS Containers That Actually Work

Docker Desktop on macOS was always a compromise: a full Linux VM running Docker. Lima gives you the same result with less overhead.

The Problem with Docker Desktop on macOS

# Docker Desktop:
# - Runs a full Alpine Linux VM (2-4GB RAM)
# - Shares your file system via osxfs (slow)
# - Virtual USB/Network stack
# - $0-$21/month depending on company size

# Lima:
# - Uses macOS native virtualization (Hypervisor.framework)
# - Better performance
# - Native file sharing (virtiofs)
# - Free and open source

Setting Up Lima

# Install
brew install lima

# Create a template
limactl start

# It creates an Alpine Linux VM with:
# - containerd + nerdctl
# - BuildKit
# - BuildPull-Through caching
# - Rootful + Rootless support

# Use it like Docker
limactl shell default docker build -t myapp .
limactl shell default docker run -p 8080:80 myapp

Custom Lima Configuration

# lima.yaml (or any .yaml in ~/.lima/_config/)
images:
  - location: "https://deps.sh/lima/alpine/3.19.1/lima.yaml"
    arch: "x86_64"
  - location: "https://deps.sh/lima/alpine/3.19.1/lima.yaml"
    arch: "aarch64"

provision:
  - mode: system
    script: |
      # Install containerd and dependencies
      apk add --no-cache 
        containerd 
        docker 
        docker-cli-compose 
        buildkit

  - mode: user
    script: |
      # User-level setup
      systemctl --user enable containerd
      systemctl --user start containerd

provision_scripts:
  - mode: system
    script: |
      cat > /etc/docker/daemon.json <<'EOF'
      {
        "registry-mirrors": ["https://mirror.gcr.io"],
        "storage-driver": "overlay2"
      }
      EOF
      rc-service docker start

mounts:
  - location: "~"
    writable: true
  - location: "/tmp/lima"
    writable: true

networks:
  - lima: bridged

cpu: 4
memory: 8GB
disk: 100GB

containerd: The Standard for Production

containerd is what runs inside Docker and Kubernetes. You can use it directly for simpler, more secure deployments.

Why Use containerd Directly

# Docker stack (Docker Inc.'s product):
# docker CLI → dockerd (daemon) → containerd → runc → containers

# containerd directly:
# ctr CLI (or nerdctl) → containerd → runc → containers

# Benefits:
# - Smaller attack surface (no dockerd)
# - Direct access to OCI images
# - Better integration with Kubernetes
# - Simpler debugging

Using ctr (containerd CLI)

# Install
apt install containerd

# Pull images
ctr images pull docker.io/library/nginx:alpine

# List images
ctr images ls

# Run containers
ctr run -t --rm docker.io/library/alpine:latest test-container ash

# Manage namespaces (like docker ps)
ctr ns ls
ctr -n k8s.io containers ls

nerdctl: Docker-Compatible CLI for containerd

# Install nerdctl
brew install nerdctl

# nerdctl works like docker but uses containerd
nerdctl build -t myapp:latest .
nerdctl run -p 8080:80 myapp:latest
nerdctl compose up

# Extra features nerdctl adds:
# - Image encryption (--encrypt)
# - BuildKit with containerd snapshotter
# - Gzip compression for images
# - Lazy pulling (stargz)

BuildKit: Faster Builds with Cache Mounts

BuildKit is the modern builder for Docker/Podman/containerd. It handles concurrent builds, better caching, and more efficient layer management.

BuildKit.toml

# /etc/buildkit/buildkitd.toml
[registry."docker.io"]
  mirrors = ["registry.docker.io"]

[registry."gcr.io"]
  insecure = true  # For air-gapped environments

[worker.oci]
  max-parallelism = 4  # Limit concurrent builds

[driver]
  snapshotter = "overlayfs"  # Faster than native

Build Commands with Cache

# Build with inline cache (embed cache metadata in image)
docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t myapp:latest .

# Build with cache mount (persist package manager caches)
docker build -t myapp:latest . <<'EOF'
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm 
    npm ci --only=production
COPY . .
EOF

# The npm cache persists across builds
# `npm ci` runs with the cached node_modules

Multi-Platform Builds

# Build for multiple architectures simultaneously
docker buildx create --use
docker buildx inspect --bootstrap

docker buildx build 
  --platform linux/amd64,linux/arm64 
  --tag myapp:latest 
  --push 
  .

# This builds simultaneously on:
# - amd64 (Intel/AMD)
# - arm64 (Apple Silicon, ARM servers)
# and pushes a manifest list to the registry

Kubernetes with containerd

# Kubernetes node configuration for containerd
# /etc/containerd/config.toml

version = 2

[plugins."io.containerd.grpc.v1.cri"]
  sandbox_image = "registry.k8s.io/pause:3.9"

  [plugins."io.containerd.grpc.v1.cri".containerd]
    default_runtime_name = "runc"
    snapshotter = "overlayfs"

    [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
      runtime_type = "io.containerd.runc.v2"
      privileged_without_host_devices = false

      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
        BinaryName = "/usr/bin/runc"
        SystemdCgroup = true

  [plugins."io.containerd.grpc.v1.cri".registry]
    config_path = "/etc/containerd/certs.d"

The Decision Framework

Tool Best For Installation Complexity
Docker Beginners, cross-platform dev Easy Low
Podman Security-conscious, Linux dev Easy Medium
Lima macOS users who want performance Easy Medium
containerd Production K8s nodes Manual High

The Cost of Docker Desktop

# Docker Desktop pricing (2026):
# - Individuals: Free
# - Small business (<250 employees, <$10M): Free
# - Medium business: $21/month/user
# - Large business: Commercial license required

# Alternatives:
# - Podman: Free
# - Lima: Free
# - Rancher Desktop: Free (macOS/Windows)
# - OrbStack: Free (macOS, faster than Lima)

The Bottom Line

Docker isn’t going away — it’s still the most compatible and well-documented option. But in 2026, you have real choices:

  • macOS users: Try OrbStack or Lima before Docker Desktop
  • Security-conscious teams: Podman is now production-ready
  • Kubernetes users: You already use containerd; consider using it directly
  • Everyone else: Docker still works fine

The days of “Docker is containers” are over. Containers are infrastructure, and infrastructure deserves thoughtful choices.

Using an alternative to Docker in 2026? What’s your setup?

TypeScript 5.5 — The Features That Actually Matter for Production Code

TypeScript 5.5: The Features That Actually Matter for Production Code

TypeScript 5.5 shipped with a mix of headline features and subtle improvements that compound significantly in large codebases. After running it in production for months, here are the features that actually moved the needle for our team.

The Big One: Inferred Type Predicates

This sounds small but it’s a massive DX improvement. TypeScript can now automatically infer type predicates from function implementations.

Before 5.5: The Verbose Manual Fix

// Before: TypeScript couldn't narrow the type
function isString(value: unknown): boolean {
  return typeof value === 'string';
}

const values: (string | number)[] = ['hello', 42, 'world', 100];

// You'd have to do this:
const strings = values.filter((v) => {
  if (isString(v)) {
    return v; // TypeScript still thought v could be number!
  }
  return false;
});

// Or this (more explicit but verbose):
const strings = values.filter((v): v is string => isString(v));

After 5.5: Automatic Inference

// Now TypeScript infers the type predicate automatically
function isString(value: unknown): boolean {
  return typeof value === 'string';
}

const values: (string | number)[] = ['hello', 42, 'world', 100];

// TypeScript now correctly narrows inside the filter callback
const strings = values.filter((v) => isString(v));
// strings is correctly typed as string[]

// Works with more complex predicates
function isNonNullable<T>(value: T): value is NonNullable<T> {
  return value !== null && value !== undefined;
}

const mixed: (string | null | undefined)[] = ['a', null, 'b', undefined, 'c'];
const defined = mixed.filter(isNonNullable);
// defined is string[]

Where This Compounds: Data Validation Chains

// Before 5.5 required explicit type predicates everywhere
interface User {
  id: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

function isAdmin(user: User): boolean {
  return user.role === 'admin';
}

function isActive(user: User): boolean {
  return user.email.includes('@'); // simplified
}

const users: User[] = /* from API */;

// Old way: explicit type predicates
const admins = users.filter((u): u is User => isAdmin(u) && isActive(u));

// 5.5 way: inferred predicates chain naturally
const admins = users.filter((u) => isAdmin(u) && isActive(u));

Regular Expression Syntax Checking

TypeScript 5.5 adds type checking for regular expressions. This catches real bugs.

Caught at Compile Time

// This would have been a runtime error before
const emailRegex = new RegExp('[a-z+', 'i'); // Syntax error in regex!

// TypeScript 5.5: Error detected at compile time
// Error: Invalid regular expression: Range out of order in character class

// Another example
const dateRegex = new RegExp('(\d{4})-(\d{2})-(\d{2}', 'g'); // Missing closing paren
// Error: Invalid regular expression: Unterminated group

The Subtle Case It Catches

// Oops: accidentally escaped the wrong character
const phoneRegex = new RegExp('\d{3}[-*]\d{3}[-*]\d{4}');
// Valid! But what if you meant:

const zipRegex = new RegExp('d{5}'); // Missing backslash!
// TypeScript 5.5 catches this: 'd' should be '\d' in string
// Error: Invalid escape sequence in string literal

Object Types from Array.filter()

When you filter an array with a type predicate that narrows to a specific interface, TypeScript now properly infers the output type.

interface ApiResponse {
  status: 'success' | 'error';
  data?: object;
  error?: string;
}

const responses: ApiResponse[] = await fetchAll();

// Before 5.5: You had to manually type the filter result
const successful: ApiResponse[] = responses.filter((r) => r.status === 'success');
// This worked but was verbose for more complex scenarios

// 5.5 handles discriminated unions better
const successes = responses.filter((r) => r.status === 'success');
// successes is (ApiResponse & { status: 'success' })[] but crucially
// r.data is now properly typed as `object` (not `object | undefined`)

The import Attributes Fix

// Before 5.5: Weird edge cases with import assertions
import data from './data.json' assert { type: 'json' };

// 5.5: import attributes syntax (the new standard)
import data from './data.json' with { type: 'json' };

// This matters because `assert` is being deprecated in favor of `with`
// 5.5 ensures you're writing future-proof code

Performance: Faster Builds

TypeScript 5.5 brought meaningful build speed improvements. In our codebase (~800k lines, 300 packages):

TypeScript 5.4: 45.2s full build
TypeScript 5.5: 38.7s full build
Improvement: ~14%

With --incremental:
TypeScript 5.4: 12.1s incremental
TypeScript 5.5: 9.8s incremental
Improvement: ~19%

The speedup comes from:

  • More efficient type narrowing
  • Smarter stale file detection
  • Better caching of resolved imports

The new satisfies Behavior

// 5.5 refines what `satisfies` means for union types
type Color = 'red' | 'green' | 'blue';
type Theme = Record<string, Color | string>;

const theme = {
  primary: 'red',
  secondary: 'blue',
  custom: '#3498db', // This is valid (string, not Color)
} satisfies Theme;

// TypeScript now correctly narrows each property
const primary: Color = theme.primary; // 'red' (not Color | string)
const custom: string = theme.custom; // '#3498db' (not Color | string)

// Before 5.5, you sometimes had to cast even with satisfies

What Didn’t Change (And That’s Okay)

You Still Need Explicit Type Annotations for API Boundaries

// This still requires explicit types - TypeScript can't infer from runtime
async function fetchUser(id: string): Promise<User> {
  // You still need to tell TypeScript what User is
  const response = await fetch(`/api/users/${id}`);
  return response.json(); // still any, needs type assertion or schema validation
}

// Best practice: use a schema validator (zod, typebox)
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
});

const response = await fetch(`/api/users/${id}`);
const user = UserSchema.parse(await response.json()); // typed correctly

Complex Generics Still Need Help

// TypeScript still struggles with complex inference in some cases
function createReducer<S, A extends Action>(
  initialState: S,
  handlers: Record<string, (state: S, action: A) => S>
): Reducer<S, A> {
  return (state = initialState, action) => {
    const handler = handlers[action.type];
    return handler ? handler(state, action) : state;
  };
}

// The inference still requires explicit types in some scenarios
// This is fundamental to how TypeScript's type system works

Migration Guide: What to Change

Step 1: Update TypeScript

npm install [email protected] --save-dev
# or
pnpm add -D [email protected]

Step 2: Find Type Predicate Boilerplate to Remove

Search your codebase for value is patterns in filter callbacks:

// This pattern is now unnecessary in most cases:
array.filter((item): item is Type => predicate(item))

// Can become:
array.filter(predicate)

Step 3: Update import assertions to import attributes

// Replace:
import foo from './foo.json' assert { type: 'json' };

// With:
import foo from './foo.json' with { type: 'json' };

Step 4: Add RegExp Validation (Optional but Recommended)

// Add a lint rule to catch regex errors early
// ESLint rule: no-invalid-regexp

// Or add a custom rule in your CI:
const regex = new RegExp(pattern);
console.log('Regex valid:', regex.source);

The Bottom Line

TypeScript 5.5 isn’t a revolutionary release, but it’s a solid incremental improvement. The inferred type predicates alone save hours of boilerplate in large codebases. The performance improvements compound on every build.

The real value: TypeScript is getting better at reducing the gap between what you write and what TypeScript understands, without requiring increasingly complex type gymnastics.

Running TypeScript 5.5? What’s your experience been? Any surprises — good or bad?

Upgrade with confidence: npm install -D [email protected] && npx tsc --version

Database Migration Strategies That Actually Work in Production

Database Migration Strategies That Actually Work in Production

Database migrations are the thing that looks simple until you’re凌晨3点 trying to recover from a migration that locked your production table for 45 minutes. After running migrations on systems with billions of rows, here’s what I’ve learned about doing them safely.

The Fundamental Problem

Most migration tutorials show you this:

class AddIndexesToOrders < ActiveRecord::Migration[7.0]
  def change
    add_index :orders, :user_id
  end
end

This is fine in development. In production with 50 million orders, this will lock your table and bring down your application.

The Core Principle: Small Changes, Applied Incrementally

Every production-safe migration follows the same pattern: make the change in small, non-breaking steps that can be rolled back independently.

Expanding-Contract Pattern

Instead of one big migration, use three:

Migration 1: Add new column (nullable)

-- Migration 1: Safe - never locks
ALTER TABLE orders ADD COLUMN user_email VARCHAR(255);

-- Update application to write BOTH old and new columns
-- Deploy this first

Migration 2: Backfill data (in batches)

-- Safe batched backfill
DO $$
DECLARE
  batch_size INT := 10000;
  offset_val INT := 0;
  max_id INT;
  updated INT;
BEGIN
  SELECT MAX(id) INTO max_id FROM orders;

  LOOP
    UPDATE orders 
    SET user_email = (SELECT email FROM users WHERE users.id = orders.user_id)
    WHERE id IN (
      SELECT id FROM orders 
      WHERE user_email IS NULL 
      AND id <= max_id
      ORDER BY id 
      LIMIT batch_size
    );

    GET DIAGNOSTICS updated = ROW_COUNT;
    EXIT WHEN updated = 0;

    -- Prevent lock contention
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

Migration 3: Add NOT NULL constraint

-- Now safe because all rows have values
ALTER TABLE orders ALTER COLUMN user_email SET NOT NULL;

Handling Long-Running Migrations

The Lock Timeout Strategy

-- Set a short lock timeout so migration fails fast instead of hanging
SET lock_timeout = '2s';

-- Migration that might need a lock
ALTER TABLE orders ADD COLUMN status VARCHAR(50);

-- If it can't get lock in 2s, it fails immediately
-- Instead of blocking for minutes

Concurrent Index Building

Never use CREATE INDEX in production. Always use CREATE INDEX CONCURRENTLY.

-- BAD: Locks table, blocks reads/writes
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- GOOD: Runs without locking, takes longer but zero downtime
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

Critical note: CREATE INDEX CONCURRENTLY cannot run inside a transaction. Your migration framework needs to handle this.

# Rails: Tell it to run outside a transaction
class AddIndexesToOrders < ActiveRecord::Migration[7.0]
  disable_ddl_transaction!

  def change
    add_index :orders, :user_id, algorithm: :concurrently
  end
end

Schema Versioning: The Branching Model

For complex systems, treat database schema like code with proper branching:

main (production schema)
  └── staging-test (validate migrations)
        └── feature/user-email-migration (your change)
# Before starting a migration
git checkout main
git pull
git checkout -b migration/user-email-fix

# Run migrations locally against fresh production copy
# Once validated:
git checkout main
git merge migration/user-email-fix
# Deploy migration to production

The Pre-Migration Checklist

Before ANY production migration:

□ Tested on production-size dataset (at minimum on staging with production data snapshot)
□ Lock duration estimated (use EXPLAIN ANALYZE)
□ Rollback plan documented
□ Canary/deploy step prepared (migrate 1% of traffic, observe, then full deploy)
□ Alert thresholds set (if migration causes >X% error rate, auto-rollback)
□ Migrations scheduled during low-traffic window
□ On-call engineer aware and standing by
□ Database backup verified (point-in-time recovery tested)
□ Lock timeout set appropriately
□ Query plan examined for full table scans

Real-World Example: Renaming a Column Safely

Renaming a column is a four-migration process:

Migration 1: Add new column (double-write starts)

ALTER TABLE users ADD COLUMN display_name VARCHAR(100);
# Update application to WRITE to both columns
# User.where(name: 'John').update(display_name: 'John') 
# runs in background

Migration 2: Backfill

UPDATE users SET display_name = name WHERE display_name IS NULL;
-- In batches of 10,000 with 0.1s sleep

Migration 3: Stop reading from old column

# Deploy code that only reads from display_name
# Verify everything works

Migration 4: Drop old column

ALTER TABLE users DROP COLUMN name;
-- Must run outside transaction for PostgreSQL

PostgreSQL-Specific Tools

pg_repack: Remove Bloat Without Table Locks

# Install
CREATE EXTENSION pg_repack;

# Repack a bloated table without locking
pg_repack -d mydb -t orders --no-indexes

# Repack with specific index
pg_repack -d mydb -t orders -i idx_orders_user_id

pg_activity: Monitor Migration Progress

# Watch active queries during migration
pg_activity -h localhost -U postgres

# Or query directly
SELECT pid, state, query, query_start, now() - query_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

The Migration Locking Hierarchy

Understanding lock modes prevents surprises:

Lock Mode Blocks
Access Share DROP TABLE, TRUNCATE
Row Share DELETE, UPDATE, SELECT FOR UPDATE
Row Exclusive INSERT, UPDATE, DELETE
Share Update Exclusive ANALYZE, CREATE INDEX CONCURRENTLY
Share CREATE INDEX (blocking)
Share Row Exclusive ALTER TABLE
Exclusive REFRESH MATERIALIZED VIEW CONCURRENTLY
Access Exclusive DROP TABLE, TRUNCATE, most ALTER TABLE

The Golden Rule

If your migration takes more than 100ms on a production table, it’s wrong. Go back and break it into smaller pieces.

The goal is always: zero downtime, zero data loss, instant rollback capability.

What migration horror stories do you have? What’s your go-to strategy for risky migrations?

What I Learned Building My First Express Server

I’m primarily a frontend developer. React, Vite, Tailwind etc. that’s my world. The backend has always been that thing I knew existed but rarely touched. Today I finally sat down and built my first Express server from scratch, and I want to document everything that actually clicked, including the things that didn’t at first.

This isn’t exactly a clean tutorial. It’s a honest account of what I learned, what troubled me, and what I’d tell myself if I was starting over.

Setting up from zero

First things first:

“`in your terminal run
npm init -y
npm install express




Then I created `server.js` and wrote the minimum possible server:


const express = require('express');

const app = express();

app.get('/', (req, res) => {
  res.send('Hello world');
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});


```javascript

Run it with `node server.js`, open `http://localhost:3000` in the browser — "Hello world". That's a working HTTP server in about 10 lines.

Let me break down what each part is actually doing.

---

**Breaking down the code**

### `const express = require('express')`

This pulls the Express library from `node_modules` into the current file. `require` is CommonJS — the default module system in Node. You store it in a variable called `express`.

### `const app = express()`

You call `express()` like a function because it is one. It returns an application object — this `app` is your server's control panel. Everything attaches to it.

### The route


app.get('/', (req, res) => {
  res.send('Hello world');
});

This registers a route. Let’s read it out loud:

“When someone makes a GET request to /, run this function.”

req is the request object — everything the client sent you. Their URL, query params, headers, body.

res is the response object — your tools for sending something back.

app.listen(3000, () => { ... })

This starts the server on port 3000. The callback runs once the server is ready — that’s why you log there and not outside it.

The req/res naming thing

I assumed req and res were special keywords that Express required. They’re not. They’re just parameter names.

Express calls your callback with the request object first and the response object second, always. The names are completely up to you:

// All of these work identically
app.get('/', (req, res) => { res.send('ok') });
app.get('/', (request, response) => { response.send('ok') });
app.get('/', (banana, mango) => { mango.send('ok') });

That last one is valid JavaScript. mango holds the response object and .send() works fine on it.

The community standardized on req and res so everyone reading your code immediately knows which is which. Stick with the convention, but knowing it’s just a convention makes the whole thing less mysterious.

Reading data from the URL

There are two ways a client can send data in a GET request.

URL parameters

app.get(‘/user/:id’, (req, res) => {
const id = req.params.id;
res.json({ userId: id });
});

The colon before id tells Express that this segment of the URL is a variable. So /user/jeffrey, /user/42, and /user/anything all hit this route. Whatever’s in that slot gets stored in req.params.id.

You visit it like: http://localhost:3000/user/jeffrey

For multiple params, it’s a slash between them, not a comma:

app.get(‘/add/:a/:b’, (req, res) => {
// correct
});

app.get(‘/add/:a,:b’, (req, res) => {
// wrong — this will not match the way you expect
});

I made the comma mistake. It cost me time.

Query parameters

app.get(‘/add’, (req, res) => {
const a = Number(req.query.a);
const b = Number(req.query.b);
res.json({ sum: a + b });
});

You visit it like: http://localhost:3000/add?a=10&b=5

When to use which:

URL params are for identifying a specific resource — /user/42, /product/air-max. Query params are better for operations, filters, and optional values — /search?q=express, /add?a=10&b=5. For a math operation, query params are the natural fit.

Splitting logic into modules

Instead of writing all my functions inside the server file, I created a separate math.js:

// math.js
function add(a, b) {
return a + b;
}

function mult(a, b) {
return a * b;
}

module.exports = { add, mult };


Then imported it in the server:

js
// server.js
const { add, mult } = require(‘./math’);


This keeps the server file clean. Routes handle HTTP, modules handle logic. Good habit to build early.

---

** CommonJS vs ESModules**

Node has two module systems and I spent time going back and forth between them today.

**CommonJS:**

js
const express = require(‘express’);
module.exports = { add };


**ESModules:**

js
import express from ‘express’;
export function add(a, b) { return a + b; }


The key differences:

- To use ESM in Node, you need `"type": "module"` in your `package.json`, otherwise Node treats `.js` files as CJS by default
- ESM requires file extensions when importing local files: `./math.js` not `./math`
- You cannot mix them freely. `require` in an ESM file breaks. `import` in a CJS file breaks.

**My recommendation if you're just starting:** stick with CommonJS. No config needed, works out of the box, and the vast majority of Express tutorials and packages are written in it. Switch to ESM when you have a concrete reason to.

---

## Handling POST requests

GET requests carry data in the URL. POST requests carry data in the **body** — like submitting a login form or hitting an API with JSON. You don't want a password showing up in someone's browser history.

js
app.post(‘/login’, (req, res) => {
const { username, password } = req.body;
res.json({ message: Welcome ${username} });
});


But there's a catch. To read `req.body`, you need this line **before** your routes:

js
app.use(express.json());


`app.use()` applies middleware to every incoming request. `express.json()` reads the raw request body and parses it as JSON so you can access it via `req.body`.

I removed this line to see what would happen. Got this:

plaintext
TypeError: Cannot destructure property ‘username’ of ‘req.body’ as it is undefined.


Without it, `req.body` is just `undefined`. The middleware is not optional — and it must come before your routes, not after.

To test POST requests, you can't use the browser URL bar. Use **Thunder Client** (VS Code extension) or Postman. Send a POST to `http://localhost:3000/login` with this JSON body:

json
{
“username”: “jeffrey”,
“password”: “velto123”
}


Response:

json
{
“message”: “Welcome jeffrey”
}

Small things that made me question my sanity

1. The app.listen callback

// Wrong — logs before the server is ready
app.listen(3000, console.log("Server running on port 3000"));

// Right — logs once the server is actually up
app.listen(3000, () => console.log("Server running on port 3000"));

Without the arrow function, console.log executes immediately when the file loads, not when the server is ready. It’s a subtle difference but it matters.

2. res.send() vs res.json()

res.send() is for plain text. res.json() is for objects and structured data. When you’re building an API, you almost always want res.json().

3. Not restarting the server

Every time I made a change and wondered why nothing was different — the old server was still running. Fix this once:

node --watch server.js

Built into Node 18+. No install needed. Your server restarts automatically on every file change.

Where I ended up

By the end of the session I had a working Express server with:

  • GET routes reading from URL params and query params
  • A separate math module with add and mult functions
  • A POST route reading from req.body
  • A solid mental model of how the whole thing fits together

If you’re a frontend developer who’s been avoiding Express the same way I was — it’s genuinely not that far from home. The concepts map to things you already know. A route is just an event listener. Middleware is just a function that runs before your handler. req and res are just objects.

Start with a GET route. Break something. That’s how it sticks.

I’m Chinwuba Jeffrey — frontend developer and CEO of Velto, a premium web design agency focused on the African market. I’m documenting my journey going full-stack. Follow along if you’re on a similar path.

I Build the Infrastructure That Serves AI Models. Gemma 4 Just Made My Job Existential.

Gemma 4 Challenge: Write about Gemma 4 Submission

This is a submission for the Gemma 4 Challenge: Write About Gemma 4

Most posts about Gemma 4 start with “I downloaded it and ran it locally.” Mine starts differently.

I build the platform that serves models like Gemma 4. I spend my days writing Kubernetes manifests, configuring KServe InferenceServices, debugging Knative ingress routes, and making sure Kyverno policies block the bad deployments before they reach the cluster. My project — NeuroScale — is a self-service AI inference platform where a developer fills in a Backstage form, the platform creates a pull request, ArgoCD deploys it, and a production-grade inference endpoint goes live.

I am, in the most literal sense, the person who keeps the lights on for AI model serving.

So when Google released Gemma 4 with models ranging from 2B parameters on a Raspberry Pi to 31B on a workstation, my first thought wasn’t “cool, let me try it.” My first thought was: “Does this make my entire platform unnecessary?”

That question — and the answer I eventually reached — is what this post is about.

The Serving Tax Nobody Talks About

Here’s a dirty secret from platform engineering: most of the cost of running an AI model isn’t the model. It’s everything around it.

On NeuroScale, deploying a single sklearn InferenceService on KServe creates roughly 5 pods. Not one. Five. A predictor pod, a transformer (if configured), an Istio/Kourier sidecar or gateway pod, a queue-proxy injected by Knative, and a storage initializer that downloads model artifacts at startup.

Each of those pods needs CPU requests, memory limits, health probes, and — because we enforce this through Kyverno admission policies — ownership labels and cost-center attribution before they’re even allowed to exist:

# This gets DENIED by our Kyverno policy — no owner, no deploy
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: gemma-model
  namespace: default
spec:
  predictor:
    sklearn:
      storageUri: gs://models/gemma/v1
# ❌ Blocked: missing app.kubernetes.io/owner and cost-center labels

On a local k3d cluster — the kind I use for development — a single InferenceService consumes around 1.2 GB of RAM. Add Backstage (the developer portal), ArgoCD (GitOps), Kyverno (policy engine), and OpenCost (cost attribution), and you’re at 6-8 GB of infrastructure memory before you’ve served a single prediction.

This is the serving tax. Every platform engineer pays it. Nobody writes blog posts about it.

Then Gemma 4 E2B Showed Up on a Raspberry Pi

When I read that Gemma 4’s E2B model — 2 billion parameters, multimodal, 128K context window — runs on a Raspberry Pi 5 drawing 7.5 watts, something clicked.

Not “clicked” as in excitement. “Clicked” as in a core assumption quietly becoming wrong.

My entire platform exists because of a specific premise: deploying AI models is hard, dangerous, and requires infrastructure guardrails. The selling point of NeuroScale is that you don’t need to understand Kubernetes to get an inference endpoint. You fill in a form. The platform handles the YAML, the drift correction, the policy enforcement, the cost tracking.

But what if the model just… runs? On the device? With no YAML? No Kubernetes? No platform?

Gemma 4’s model family makes this question concrete in a way previous open models didn’t:

Model Parameters Runs On Needs Platform?
E2B 2B Phone, Raspberry Pi, browser No
E4B 4B Laptop, edge device No
31B Dense 31B Workstation GPU Maybe
26B MoE 26B (4B active) Server Yes

The bottom two rows are my world. The top two rows are… not.

The Three-Hour Outage That Explains Everything

Let me tell you about the worst three hours of my NeuroScale build.

When I first set up KServe, all InferenceService creation was blocked cluster-wide. No model could deploy. Zero. The reason? KServe’s default configuration assumes you’re running Istio as your service mesh. We were running Kourier — a lightweight Envoy-based ingress gateway that uses ~100 MB instead of Istio’s ~1 GB — because our k3d cluster didn’t have the RAM headroom for Istio.

The fix was a single configuration patch:

# infrastructure/serving-stack/patches/inferenceservice-config-ingress.yaml
ingress:
  disableIstioVirtualHost: true

One line. disableIstioVirtualHost: true. That’s what stood between “all inference is blocked” and “everything works.” And this configuration flag isn’t in KServe’s getting-started documentation. I found it by reading the KServe controller source code.

Three hours of production-blocking downtime. One boolean.

Now here’s the thing: if I had been running Gemma 4 E2B locally via Ollama, this outage would not have existed. There’s no ingress to misconfigure. There’s no service mesh. There’s no Kubernetes. There’s just a process listening on a port.

# The entire "platform" for local Gemma 4
ollama run gemma4:e2b
# That's it. No YAML. No CrDs. No three-hour outage.

So why do platforms like mine still exist?

Because “Running a Model” and “Serving a Model” Are Different Problems

This is the nuance I think most Gemma 4 coverage misses.

Running a model means: download weights, load into memory, send a prompt, get a response. Gemma 4 E2B does this beautifully on consumer hardware.

Serving a model means: make it available to 50 developers across 3 teams, ensure the person who deployed it is accountable for its resource cost, prevent someone from accidentally deploying a root container with no memory limits that crashes the shared cluster, automatically roll back when the model artifact URI changes and the new version returns garbage, and produce an audit trail that shows exactly who deployed what, when, and why.

That’s not a model problem. That’s an organizational problem. And Gemma 4 doesn’t solve it — no model does.

On NeuroScale, a single deployment goes through this gauntlet:

Developer fills Backstage form
    → Platform generates YAML with required labels
    → PR is created on GitHub
    → CI runs kubeconform (schema validation)
    → CI runs kyverno-cli (policy simulation)
    → CI calculates resource delta (cost impact)
    → PR is reviewed and merged
    → ArgoCD detects the change
    → ArgoCD applies the manifest
    → Kyverno admission webhook validates live
    → KServe creates the InferenceService
    → Knative provisions the serving infrastructure
    → Smoke tests verify the endpoint responds

Thirteen steps. Most of them invisible to the developer. All of them essential when you’re running models for an organization, not for yourself.

The False-Green That Changed How I Think About AI Governance

Here’s a story that connects Gemma 4’s model selection philosophy to platform engineering in a way I didn’t expect.

For two weeks, our CI pipeline was reporting that all Kyverno policy checks passed. Green checkmarks everywhere. PRs merged with confidence. We thought our guardrails were working.

They weren’t.

The kyverno-cli apply command exits with code 0 even when policies are violated. Zero. Success. The violation details are printed to stdout, but the exit code — the thing CI systems use to determine pass/fail — says “everything’s fine.”

# This exits 0. CI shows green. Policies are NOT enforced.
kyverno-cli apply ./policies/ --resource ./bad-manifest.yaml
echo $?  # 0 ← This is a lie

The correct tool for CI policy validation is kyverno test, which handles exit codes properly and is purpose-built for pipelines. We were using kyverno apply — not out of ignorance, but because it was the first thing in the docs. That was the mistake: reaching for what was convenient instead of verifying it was correct. The fix required checking both the exit code and parsing stdout for violation strings:

# The real check — trust nothing
OUTPUT=$(kyverno-cli apply ./policies/ --resource "$manifest" 2>&1)
EXIT_CODE=${PIPESTATUS[0]}

if [ "$EXIT_CODE" -ne 0 ] || echo "$OUTPUT" | grep -qi "fail|violation|denied"; then
    echo "❌ Policy violation detected"
    exit 1
fi

For two weeks, any developer could have deployed an InferenceService without resource limits, without ownership labels, without cost attribution — and CI would have said “all checks passed.”

This is what governance failure looks like. Not a dramatic breach. A silent green checkmark.

What This Has to Do With Gemma 4

Gemma 4’s model family is the first time I’ve seen model selection treated as a first-class engineering decision rather than an afterthought. The challenge itself asks for “intentional model selection” — show why your model was the right tool for the job.

That phrase — intentional selection — is exactly what was missing from our CI pipeline. We selected kyverno-cli apply because it was the obvious tool. We didn’t verify that it actually enforced what we thought it enforced. The selection was convenient, not intentional.

The same trap exists with Gemma 4’s model variants. E2B is convenient — smallest download, runs anywhere. But if your use case requires multi-step reasoning over 50K tokens of context, the benchmark data shows E4B or 31B Dense is the intentional choice. If your workload needs high-throughput batch processing, the 26B MoE’s 4B active parameters per forward pass is the intentional choice.

Convenience and correctness are not the same thing. I learned that the hard way with a CI tool. Gemma 4’s model family is designed so you don’t have to.

Where Gemma 4 Actually Threatens My Platform (and Where It Doesn’t)

After 108 commits, 21 smoke tests, and 6 milestone postmortems on NeuroScale, here’s my honest assessment:

Gemma 4 Makes My Platform Unnecessary For:

Solo developers and small teams. If you’re one person running one model for your own project, you should be running Gemma 4 locally. Ollama + E2B or E4B, depending on your reasoning needs. No Kubernetes. No KServe. No platform tax. The model runs on your hardware, your data stays on your machine, and you don’t need me.

Edge and on-device inference. The E2B variant running on a phone or Raspberry Pi at 7.5W is a fundamentally different deployment model than anything platform engineers have optimized for. These devices don’t have container runtimes. They don’t need admission controllers. The compute is the deployment. This is a category Gemma 4 created, and it’s outside my scope entirely.

Prototyping and experimentation. When you’re exploring whether Gemma 4 can solve your problem, the correct infrastructure is zero infrastructure. Download the model. Try it. The 128K context window means you can feed it your entire codebase in one shot and ask questions. Don’t set up a serving platform to prototype.

Gemma 4 Makes My Platform More Necessary For:

Multi-team organizations deploying multiple models. The moment you have Team A running a 31B Dense model for code generation and Team B running an E4B for document classification on the same cluster, you need resource isolation, cost attribution, and policy enforcement. That’s not a model capability — it’s an organizational capability.

Regulated environments. Healthcare, finance, government. These industries don’t just need a model that works. They need an audit trail, admission controls, reproducible deployments, and the ability to prove that the model serving configuration hasn’t drifted from what was approved. Gemma 4’s open weights are a regulatory advantage — you control the model — but the serving infrastructure is where compliance lives.

Production SLAs. Knative’s autoscaling, KServe’s canary rollouts, ArgoCD’s self-healing drift correction — these exist because production means “it works at 3 AM on a Sunday when nobody is watching.” Running ollama serve on a workstation is not production. It’s a demo.

The Decision Framework I Wish Existed

Based on my experience building NeuroScale and studying Gemma 4’s architecture, here’s the decision tree for anyone choosing between local inference and platform-served inference:

Question If Yes → If No →
Are you one developer working alone? Local Gemma 4 (E2B/E4B) Keep reading
Is your data too sensitive to leave the device? Local Gemma 4, any variant that fits Cloud/platform OK
Do multiple teams share the same compute? You need a platform Local is fine
Do you need an audit trail for model deployments? You need a platform Local is fine
Does someone need to answer “who deployed this and what does it cost?” You need a platform Local is fine
Is the model serving a production API with SLA guarantees? You need a platform Local is fine

If you answered “no” to all six questions, run Gemma 4 locally and don’t look back.

If you answered “yes” to any of the last four, the model isn’t the hard part. The platform is.

What I’m Actually Building Next

Here’s the honest ending: Gemma 4 didn’t make my platform unnecessary. It made it smaller.

The next milestone for NeuroScale is adding Gemma 4’s 26B MoE as a first-class runtime alongside our existing sklearn InferenceService. The MoE architecture — 26B total parameters but only 4B active per forward pass — has an important nuance: MoE saves compute, not memory. Because the routing mechanism selects experts dynamically per token, all 26B weights must be resident simultaneously. At 4-bit quantization that’s ~13-15GB VRAM. What MoE does give you in a platform context is higher throughput per GPU dollar — the compute overhead per request drops dramatically, so you can serve more concurrent requests on the same hardware. That’s where the resource governance story actually lands.

# What this might look like in NeuroScale
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: gemma4-moe-team-alpha
  labels:
    app.kubernetes.io/owner: team-alpha
    cost-center: cc-ml-inference
spec:
  predictor:
    model:
      modelFormat:
        name: vllm
      runtime: gemma4-moe-runtime
      storageUri: gs://neuroscale-models/gemma4-26b-moe
      resources:
        requests:
          cpu: "4"
          memory: "16Gi"     # MoE saves compute, not memory — all 26B weights load at runtime. 16Gi works at 4-bit quant (~13-15GB), but budget for the full model, not just active params.
          nvidia.com/gpu: "1"
        limits:
          cpu: "8"
          memory: "32Gi"
          nvidia.com/gpu: "1"

The E2B and E4B? Those run on developers’ laptops directly. No platform needed. No form to fill. No PR to merge. That’s the right answer for that model tier, and I’m at peace with it.

The platform engineer’s job isn’t to serve every model. It’s to serve the models that need governance, and get out of the way for the ones that don’t.

Gemma 4’s model family, more than any other open model release, forced me to think clearly about that boundary.

The Takeaway

If you’re a developer choosing a Gemma 4 variant, here’s what a platform engineer wants you to know:

  1. E2B and E4B are genuine infrastructure breakthroughs. Not because they’re the smartest models — they’re not. Because they eliminate the serving tax entirely. No Kubernetes. No ingress. No three-hour debugging sessions. Run them locally and ship.

  2. 31B Dense and 26B MoE still need a platform. The moment you need multi-team isolation, cost attribution, or audit trails, the model’s intelligence is the easy part. The infrastructure is where the real engineering happens.

  3. Intentional model selection is governance. Choosing E2B because it’s the smallest download is convenience. Choosing E2B because your task is single-turn classification with sub-second latency requirements on edge hardware — that’s engineering. Gemma 4’s model family is the first open model release where this distinction actually matters at every tier.

  4. The most capable model is not always the right model. I learned this from a CI tool that reported false greens for two weeks. Capability without verification is worse than limited capability you actually validated.

I build platforms that serve AI models. Gemma 4 is the first model family that made me question which models deserve a platform — and that question made my platform better.

NeuroScale is open source: github.com/sodiq-code/neuroscale-platform. 108 commits, 21 smoke tests, 6 milestones of reality-check documentation. The BEFORE.md and AFTER.md tell the full story.

WebAssembly in 2026: The Quiet Revolution That Finally Delivered

WebAssembly in 2026: The Quiet Revolution That Finally Delivered

WebAssembly quietly became production-ready in 2025-2026. The browser wars settled, the toolchain matured, and suddenly WASM is everywhere: in the browser, on the server, at the edge. Here’s what changed and why it matters.

The State of WebAssembly in 2026

The big story: WASM is no longer a niche technology. It’s the runtime for:

  • Edge computing: Cloudflare Workers, Fastly Compute, AWS Lambda@Edge
  • Plugin systems: Extism, wasmtime-based extensions
  • Server-side: Node.js Deno native WASM support, Bun WASM runtime
  • Browser: Every major browser, WebAssembly System Interface (WASI)

The question is no longer “is WASM ready?” It’s “why aren’t you using it?”

What WebAssembly Actually Is

Let’s be precise: WebAssembly is a binary instruction format, not a language. You compile Rust, C++, Go, or other languages to WASM. It’s designed as a portable compilation target.

// This Rust code compiles to WASM
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[no_mangle]
pub extern "C" fn fibonacci(n: i32) -> i32 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}
// In the browser
WebAssembly.instantiateStreaming(fetch('math.wasm'))
  .then(({ instance }) => {
    console.log(instance.exports.fibonacci(40)); // Fast!
  });

The WASI Story: WebAssembly Outside the Browser

WASI (WebAssembly System Interface) is what made WASM useful outside browsers. It provides a standardized way for WASM modules to interact with system resources (files, network, clocks).

// Rust code using WASI
use std::fs;
use std::io::{self, Read};

fn main() -> io::Result<()> {
    // Read a file (via WASI)
    let mut contents = String::new();
    fs::File::open("input.txt")?.read_to_string(&mut contents)?;

    // Process it
    let word_count = contents.split_whitespace().count();
    println!("Word count: {}", word_count);

    Ok(())
}

This same Rust code runs:

  • In a browser
  • In a Node.js process
  • In a Cloudflare Worker
  • In a standalone WASM runtime

The Rust + WASM Stack in 2026

Setting Up

# Install toolchain
rustup target add wasm32-wasip1
cargo install wasm-pack

# Create a project
cargo new --lib my-wasm-project
cd my-wasm-project

The Code

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn process_data(input: &str) -> String {
    let reversed: String = input.chars().rev().collect();
    format!("Processed: {}", reversed)
}

#[wasm_bindgen]
pub fn calculate_stats(numbers: &[f64]) -> JsValue {
    let sum: f64 = numbers.iter().sum();
    let count = numbers.len() as f64;
    let mean = if count > 0.0 { sum / count } else { 0.0 };

    // Return as JSON object
    serde_wasm_bindgen::to_value(&serde_json::json!({
        "sum": sum,
        "mean": mean,
        "count": count
    })).unwrap()
}

Build and Use

# Build for web
wasm-pack build --target web

# Or for Node.js
wasm-pack build --target nodejs
// In your web app
import init, { process_data, calculate_stats } from './pkg/my_wasm_project.js';

await init(); // Load the WASM module

const result = process_data("Hello WebAssembly!");
console.log(result); // "Processed: !gninrtsnA eb lorem"

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const stats = calculate_stats(numbers);
console.log(stats.mean); // 5.5

Real Performance Numbers

Here’s where WASM actually wins:

// Pure JavaScript
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// Benchmark: fibonacci(40)
// JavaScript: ~1200ms
// WASM (Rust): ~0.8ms
// Speedup: ~1500x
Task JavaScript WASM (Rust) Speedup
Fibonacci(40) 1,200ms 0.8ms 1500x
Image resize 4K 450ms 85ms 5.3x
JSON parse 10MB 180ms 42ms 4.3x
AES encryption 95ms 12ms 7.9x

The Component Model: The Missing Piece

The WebAssembly Component Model (WCM) shipped in 2025-2026 and is the biggest thing to happen to WASM. It allows WASM modules to compose and interface with each other without shared memory.

// my-component.wit
interface math-utils {
  add: func(a: f64, b: f64) -> f64;
  multiply: func(a: f64, b: f64) -> f64;
}

world my-component {
  export math-utils;
}
// implementation
use wit_bindgen::generate;

generate!({
    world: "my-component",
    exports: {
        "math-utils": MathUtils,
    }
});

struct MathUtils;

impl exports::math_utils::Guest for MathUtils {
    fn add(a: f64, b: f64) -> f64 { a + b }
    fn multiply(a: f64, b: f64) -> f64 { a * b }
}

The component model means you can:

  • Mix Rust, Go, C++ components in one application
  • Version components independently
  • Share interfaces between languages

Where It’s Being Used in Production

Figma (Since 2017)

The original success story. Their creative tool runs in the browser at near-native speed using C++ compiled to WASM.

Google Earth (2025 Rewrite)

Rewrote their 3D rendering engine in Rust + WASM, deployed to browser and desktop.

Cloudflare Workers

2 million+ developers deploy to 300+ data centers using WASM-based edge functions. Your JavaScript runs in a WASM sandbox.

Photoshop (Web Version)

Adobe’s web port uses WASM for performance-critical image operations. You get real Photoshop tools in a browser.

AI Inference

WASM-native ML inference frameworks (wasmML, ONNX Runtime WASM) are enabling client-side AI. Llama.cpp compiled to WASM runs 7B parameter models in browsers.

The Toolchain Maturity Story

Before 2024

  • Toolchains were inconsistent
  • Debugging was painful
  • Component Model didn’t exist
  • WASI was immature

After 2026

  • wasm-pack, cargo-component, wit-bindgen are production-ready
  • Chrome DevTools has native WASM debugging
  • WASI Preview 2 is stable
  • Component Model is shipping everywhere
# The modern workflow is now straightforward
cargo add wasm-bindgen serde serde_json --target wasm32-wasip1

wasm-pack build --target web --release

# Deploy to npm, use in your build step

When to Use WebAssembly

Use WASM when you need:

  • Performance-critical computations (image processing, cryptography, scientific computing)
  • Portability across environments (browser, server, edge)
  • Sandboxed plugin systems
  • Running existing C/Rust code in browser

Don’t use WASM when:

  • You need DOM manipulation (stick with JS)
  • Your code is I/O bound, not CPU bound
  • Simple UI logic (WASM overhead isn’t worth it)
  • You need massive ecosystem packages (npm works fine)

The Extism Framework: Plugins in Your App

Extism is the framework for building plugin systems with WASM. You write plugins in any language, run them in your host application.

// My plugin (Rust)
use extism_pdk::*;

#[plugin_fn]
pub fn transform(input: String) -> FnResult<String> {
    let result = input.to_uppercase();
    Ok(result)
}
// Host application (C#)
using Extism;

var manifest = new Manifest(
    WasmFile: "transform.wasm"
);

using var plugin = new Plugin(manifest);
var result = plugin.Call("transform", "hello world");
// result = "HELLO WORLD"

The plugin runs in a WASM sandbox, isolated from the host. You can load untrusted code safely.

My Recommendation

Start with WASM when:

  1. You have a performance bottleneck in JavaScript that profiling confirms
  2. You need to run code in multiple environments (browser + edge + server)
  3. You want a safe plugin system for user-provided code

Start with Rust + WASM if:

  • You’re building performance-critical infrastructure
  • You want maximum portability
  • You’re already a Rust shop

The learning curve is real but the tooling is finally good enough that you don’t need to be a WASM expert to ship production code.

Using WebAssembly in production? What’s your stack and what problems did it solve?

Here’s a beginner-friendly guide to GitHub, especially focused on forks and pull requests, which are the core of real-world collaboration

🚀 GitHub for Beginners (Forks, Pull Requests, and Collaboration)

GitHub is where developers store code, collaborate, and contribute to projects. If Git is the engine, GitHub is the social platform built on top of it.

Let’s break it down simply.

🌍 1. What is GitHub?

GitHub is a website that hosts Git repositories online.

It lets you:

  • Store your code in the cloud
  • Collaborate with others
  • Contribute to open-source projects
  • Track changes and history

👉 Think of Git as your local notebook and GitHub as Google Drive for code.

🍴 2. What is a Fork?

A fork is a copy of someone else’s repository under your own GitHub account.

Why fork a repo?

  • You don’t have permission to edit the original project
  • You want to experiment safely
  • You want to contribute to open-source projects

How it works:

  1. You click Fork on GitHub
  2. GitHub creates a copy in your account
  3. You can edit freely without affecting the original project

🔄 3. What is a Pull Request (PR)?

A Pull Request is how you suggest changes to someone else’s project.

Think of it like:

“Hey, I made improvements—please review and add them to your project.”

🧠 Simple Flow:

  1. Fork a repo
  2. Clone it to your computer
  3. Make changes
  4. Push changes to your fork
  5. Open a Pull Request

🛠️ 4. Step-by-Step Workflow

Step 1: Fork a repository

On GitHub:

  • Open any repo you want to contribute to
  • Click Fork (top right corner)

Step 2: Clone your fork

“`bash id=”f8c1g2″
git clone https://github.com/your-username/repo-name.git
cd repo-name




---

## Step 3: Create a branch (important!)

Never work directly on main.



```bash id="b9k3m1"
git checkout -b my-feature

Step 4: Make changes

Edit files normally in your code editor.

Step 5: Add and commit changes

“`bash id=”q2r7t9″
git add .
git commit -m “Add new feature or fix bug”




---

## Step 6: Push to your GitHub fork



```bash id="l4x8n3"
git push origin my-feature

Step 7: Open a Pull Request

Go to your fork on GitHub:

  • Click “Compare & pull request”
  • Add a title and description
  • Click “Create Pull Request”

🔍 5. What happens after a Pull Request?

Once you submit a PR:

  • Maintainers review your code
  • They may request changes
  • You can update your PR with more commits
  • If approved → it gets merged 🎉

🔁 6. Keeping your fork updated

Original repos change over time. You need to sync your fork:

Add original repo as upstream:

“`bash id=”u1p9v5″
git remote add upstream https://github.com/original-owner/repo.git




### Pull latest changes:



```bash id="k7s2m8"
git fetch upstream
git merge upstream/main

🧠 7. GitHub Concepts Cheat Sheet

Term Meaning
Repo Project folder on GitHub
Fork Your personal copy of a repo
Clone Download repo to your computer
Branch Separate working version
Commit Saved change
Pull Request Request to merge changes
Merge Combine changes into main project

⚠️ 8. Common Beginner Mistakes

❌ Working on main branch

Always use a feature branch.

❌ Forgetting to fork first

You usually cannot push directly to someone else’s repo.

❌ One huge commit

Break work into small, meaningful commits.

💡 9. Real-world example

Imagine you fix a typo in a website:

  1. Fork repo
  2. Clone it
  3. Fix typo
  4. Push change
  5. Submit PR
  6. Maintainer merges it

🎉 You just contributed to open source!

🧭 Final Thoughts

GitHub is all about collaboration and contribution. Once you understand forks and pull requests, you unlock the ability to:

  • Work on real projects
  • Contribute to open source
  • Build a developer portfolio
  • Work like professionals in tech companies

If you want next, I can show you:

  • 🧪 A practice open-source project to try your first PR
  • 💼 How companies actually use GitHub (real workflow)
  • 🧠 How to write a perfect pull request description
  • ⚡ GitHub Actions (CI/CD automation basics)

Just tell me 👍