DeShack

Tutorials on Linux, WordPress and web APIs

Reading and Configuring Nginx Logs for Real Debugging

The default nginx access log is fine for counting hits and not much else. I found this out the annoying way, trying to figure out why a handful of requests were taking eight seconds while everything else on the same endpoint returned in under 200ms. The default combined format gave me the request, the status code, and the response size – nothing about where the time actually went. Was it the backend? A slow upstream connection? Nginx itself buffering something? The log couldn’t say, because it wasn’t recording the numbers that would answer the question.

What the default format is missing

The stock combined log format, defined in nginx’s default configuration, looks roughly like this:

log_format combined '$remote_addr - $remote_user [$time_local] '
                     '"$request" $status $body_bytes_sent '
                     '"$http_referer" "$http_user_agent"';

It tells you what happened and when, but not how long anything took or, if nginx is acting as a reverse proxy, what the upstream server reported back. For anything beyond basic traffic auditing – which is most real debugging – you need a custom format.

A log format built for debugging

Nginx exposes several timing and upstream variables that aren’t in any default format but are documented in the ngx_http_log_module reference. The ones I add to nearly every reverse-proxy config:

log_format timed '$remote_addr - [$time_local] "$request" '
                  '$status $body_bytes_sent '
                  'rt=$request_time uct=$upstream_connect_time '
                  'urt=$upstream_response_time '
                  'upstream=$upstream_addr ustatus=$upstream_status';

access_log /var/log/nginx/access.log timed;

$request_time is the total time nginx spent on the request, from first byte received to last byte sent to the client. $upstream_response_time is how long the backend took to respond. $upstream_connect_time is how long it took just to establish the connection to that backend. Put those three together and you can tell, without guessing, whether slowness is happening in your application, in the network path to it, or somewhere in nginx’s own handling of the client connection.

Reading the numbers

The diagnostic value is almost entirely in comparing these fields against each other, not reading any single one in isolation. If upstream_response_time is close to request_time, the backend is the bottleneck – go look at application logs, slow query logs, whatever’s behind nginx. If upstream_response_time is small but request_time is large, the time is being spent somewhere nginx controls: often a slow client on the other end of a large response, or buffering settings that are holding data longer than necessary. A high upstream_connect_time specifically points at network or backend availability issues – the backend is slow to accept a connection, not slow to respond once connected, which usually means something different is wrong (connection pool exhaustion, an overloaded backend, DNS resolution delay).

On a setup with multiple upstream servers behind a single location block, $upstream_addr and $upstream_status matter just as much – they tell you which specific backend served a slow or failing request, which turns “the API is sometimes slow” into “server three in the pool is sometimes slow,” a much more useful starting point.

Finding the outliers

Once the format includes request_time, finding slow requests is a matter of filtering the log rather than guessing:

awk '{ for (i=1; i<=NF; i++) if ($i ~ /^rt=/) { split($i, a, "="); if (a[2]+0 > 1.0) print } }' access.log

That pulls out every line where the request took more than a second, which is usually a small enough set to read through directly and spot the pattern – a specific endpoint, a specific upstream, a specific time of day correlating with a cron job or traffic spike.

Separating error log noise from real signal

The error log deserves the same attention as access logs, but its default verbosity (error level) mixes genuinely actionable problems – a backend refusing connections, a misconfigured upstream – with routine noise like clients disconnecting mid-request, which nginx logs as an error even though it’s a completely normal thing for a browser tab to do. Bumping the level down to warn for production cuts a lot of that noise:

error_log /var/log/nginx/error.log warn;

What’s left after that filter tends to be worth reading in full rather than grepping through, since it’s a much shorter list once routine client disconnects are gone.

Rotation gotchas that bite later

One thing that trips people up after adding a custom format: log rotation with logrotate renames the current log file, but nginx keeps writing to the file descriptor it already opened, which now points at the renamed (or deleted) file rather than the new one at the original path. Without a signal telling nginx to reopen its log files, you end up writing to a file nothing can see, and rotation quietly breaks logging until the next reload. The fix is a postrotate block that sends the reopen signal:

postrotate
    nginx -s reopen
endscript

It’s a small addition, but skipping it is the single most common reason a “working” custom log format stops producing anything useful a week or two after it was set up.

Backups That Actually Restore: Testing Your Disaster Recovery Plan

A backup you haven’t restored is a hypothesis, not a backup. I picked up that habit of thinking the hard way, helping a friend recover a server after a bad update wiped its database – the nightly dump job had been running successfully for months, green checkmarks the whole time, and the archive turned out to be truncated because the disk had quietly filled up mid-dump weeks earlier and nobody had checked. The job “succeeded” every night. The data it produced was garbage.

Having copies isn’t the hard part

The classic 3-2-1 rule – three copies of your data, on two different types of media, with one copy offsite – is a reasonable baseline and worth following: a local snapshot plus a copy on a different provider or region protects against both hardware failure and a single provider’s outage taking out your only backup along with your production data. But 3-2-1 answers “where do copies live,” not “will this copy actually work when I need it,” and that second question is where most backup strategies quietly fail.

Automate the restore test, not just the backup

The only way to know a backup is good is to restore it somewhere and check the result, and the only way that happens reliably is if it’s automated rather than left as a task someone means to get around to. A simple version: a scheduled job spins up a throwaway database container, restores the latest backup into it, and runs a handful of sanity checks – row counts on key tables, a checksum on a known-stable table, maybe a smoke-test query that exercises a foreign key relationship.

#!/bin/bash
set -euo pipefail

docker run -d --name restore-test -e POSTGRES_PASSWORD=test postgres:16
sleep 5
gunzip -c /backups/latest.sql.gz | docker exec -i restore-test psql -U postgres

ROWS=$(docker exec restore-test psql -U postgres -tAc "SELECT count(*) FROM orders;")
if [ "$ROWS" -lt 1000 ]; then
  echo "restore test failed: only $ROWS rows in orders" >&2
  exit 1
fi

docker rm -f restore-test

This doesn’t prove the backup is perfect, but it catches the failure mode that actually happens most often: a truncated file, a dump that finished with an error nobody looked at, a schema mismatch after an unrelated migration. Run it weekly and alert on failure the same way you’d alert on any other job, ideally through a heartbeat-style check rather than trusting the backup job’s own exit code.

Application-consistent vs crash-consistent

Filesystem or volume snapshots are convenient because they’re fast and require no application awareness, but a snapshot taken mid-write on a database is only crash-consistent – equivalent to yanking the power cord. Most databases can recover from that using their write-ahead log, but it’s a worse starting position than a clean, application-consistent dump taken with the database’s own tooling, which knows how to produce a coherent point-in-time export. For anything running Postgres or MySQL, pg_dump or a properly configured mysqldump/mariabackup run should be the backup of record, with filesystem snapshots as a fast secondary layer for full-server disaster recovery rather than the only line of defense.

Retention and verification

Keeping every backup forever is wasteful and keeping too few is dangerous – a common pattern is daily backups retained for a couple of weeks, weekly ones for a couple of months, and monthly ones for a year, giving you both a short recovery window for “I need yesterday’s data” and a longer one for “we didn’t notice this was wrong for six weeks.” Whatever the schedule, store a checksum alongside each archive and verify it before trusting the file, since silent corruption in cloud storage is rare but not impossible, and it’s a cheap check to skip only in hindsight:

sha256sum backup-2026-07-27.sql.gz > backup-2026-07-27.sql.gz.sha256
# later, before restoring:
sha256sum -c backup-2026-07-27.sql.gz.sha256

It’s two extra commands in a script that already runs unattended, and it turns “the archive looks the right size” into an actual guarantee that the bytes haven’t changed since the day they were written.

The retention window also has to account for how long a bad change can go unnoticed. If corrupted or incorrectly deleted data sometimes isn’t spotted for a month, a two-week retention policy has already lost the clean copy by the time anyone realizes something needs restoring. This is the actual argument for monthly archives kept for a year rather than aesthetic thoroughness – they’re there for the slow-burning mistakes, not the fast ones.

Tooling that makes this easier

For file and volume-level backups, restic handles encryption, deduplication, and multiple storage backends (S3, Backblaze, SFTP) out of the box, and its restic check command specifically verifies repository integrity – worth running on a schedule alongside your own restore test. Database-native dumps still belong alongside it for anything relational; the two approaches cover different failure modes and neither fully substitutes for the other.

None of this needs to be elaborate. It needs to be automatic, and it needs to actually run the restore, because the backup job that’s never been tested is the one that fails you exactly when you can’t afford it.

Database Migrations Without Downtime: A Practical Guide

The migration that taught me this lesson looked completely safe: add a NOT NULL column with a default value to a table with a few million rows. It ran locally in under a second against a test database with a few hundred rows. Against production it took long enough to hold a lock that queued every other write to that table, and the app started timing out on anything that touched it. The migration itself wasn’t wrong. The assumption that “it worked in dev” meant “it’s safe in production” was.

Why naive migrations break things

Most of the danger comes from locks and from the gap between when a schema change lands and when every running instance of your app has picked it up. Adding a column with a default in older Postgres versions used to rewrite the entire table under an exclusive lock; MySQL’s ALTER TABLE has similar rewrite behavior for many operations depending on storage engine and version. Renaming or dropping a column that running application code still references breaks requests the moment the migration commits, regardless of how fast it runs, because old app code and new schema are now out of sync for however long your deploy takes to roll out.

Postgres has improved a lot here – since version 11, adding a column with a constant default no longer rewrites the table – but the general problem doesn’t disappear: any change that a currently-running version of your app doesn’t expect is a landmine, independent of how the database executes it.

The expand/contract pattern

The reliable approach is to split what feels like one migration into several independent, backward-compatible steps deployed separately:

  • Expand: add the new column, table, or index without removing anything old. Make it nullable or give it a safe default so existing code that doesn’t know about it keeps working.
  • Backfill: populate the new column for existing rows, in batches, without touching the write path old code depends on.
  • Migrate reads and writes: deploy application code that writes to both old and new locations, then code that reads from the new one, confirming correctness along the way.
  • Contract: once nothing references the old column or table anymore, drop it in its own migration.

This is more deploys than a single ALTER TABLE, but each step is small enough to reason about and to roll back independently. If step three reveals a data problem, you haven’t already dropped the column you’d need to fall back to.

Backfilling large tables safely

A single UPDATE touching millions of rows takes the same kind of lock as the bad column-add above, just for longer. Batch it instead – update a few thousand rows at a time, in a loop, with a short pause between batches so replication and other traffic have room to breathe:

DO $$
DECLARE
  rows_updated int;
BEGIN
  LOOP
    UPDATE orders SET status_v2 = map_status(status)
    WHERE status_v2 IS NULL
    AND id IN (SELECT id FROM orders WHERE status_v2 IS NULL LIMIT 5000);

    GET DIAGNOSTICS rows_updated = ROW_COUNT;
    EXIT WHEN rows_updated = 0;
    PERFORM pg_sleep(0.25);
  END LOOP;
END $$;

For MySQL, tools like gh-ost automate this batching for full schema changes, applying them via a shadow table and cutting over with minimal locking – worth reaching for once tables get large enough that even batched manual updates feel risky.

Indexes deserve the same care

Adding an index on a large table with a plain CREATE INDEX locks out writes for the duration of the build. Postgres’s CREATE INDEX CONCURRENTLY builds the index without holding that lock, at the cost of taking longer and needing a retry if it fails partway through – a trade worth making on any table your app is actively writing to.

Framework tooling helps, but doesn’t remove the thinking

Django, Rails, and most modern migration frameworks will happily generate a migration that adds a non-nullable column with a default in one step, and it’ll work fine in development. None of them know your production table has forty million rows or that a background job is writing to it every second. The framework gets you a correct migration; it’s still on you to decide whether it needs to be split into expand/backfill/contract before it touches a table anyone depends on. The rule of thumb I use: if the table is small and the app is rarely deployed at that exact moment, one step is fine. If either of those isn’t true, split it.

Set a lock timeout, always

One habit that’s saved me more than once, independent of everything above: set a short statement or lock timeout for migration sessions specifically, so a migration that’s about to block the whole table fails fast and loudly instead of quietly queuing up every other query behind it for minutes.

SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN status_v2 text;

If the ALTER TABLE can’t grab its lock within two seconds because something else is holding a long-running transaction against that table, it errors out immediately. That’s a migration you can retry at a quieter moment, rather than one that silently piles up a queue of blocked queries behind it until something upstream starts timing out and pages someone. A failed migration is an easy problem. A migration that succeeded but took the app down for four minutes on the way is a much harder one to diagnose after the fact.

Why You Need a Staging Environment (and How to Build One Cheaply)

I once tested a database migration by running it directly against production during a quiet hour, because there was nowhere else to run it. It worked – that time. The next similarly “safe” migration locked a table for four minutes during a traffic spike we hadn’t anticipated, because the quiet-hour assumption didn’t hold and there was no environment where that kind of mistake was cheap. That’s the entire case for staging: it’s not about mirroring production perfectly, it’s about having somewhere for things to go wrong that isn’t in front of real users.

What staging is actually for

A staging environment earns its keep on a specific, narrow set of problems: verifying a database migration before it touches real data, confirming a third-party integration still works against its real API (not a mock) with the actual credentials and rate limits you’ll hit in production, and catching environment-specific bugs that never show up on a laptop – path casing issues, missing environment variables, a build step that behaves differently without dev tooling installed.

It’s also the only honest place to test anything involving webhooks or callbacks from an external service – a CI provider notifying a deploy hook, a third-party auth provider redirecting back after login, a mail service confirming delivery. These require a publicly reachable URL and real request/response round trips that a local tunnel only approximates. I’ve seen integrations that worked perfectly against a mocked webhook payload in tests fail immediately in production because the real payload had an extra field or a different content type – the kind of mismatch staging exists to catch before it reaches a customer.

It doesn’t need to be a perfect replica. The Twelve-Factor App’s dev/prod parity principle argues for minimizing gaps between environments, but “minimizing” is the operative word – staging needs to use the same backing services and the same deploy process as production, not the same hardware scale or the same customer data volume.

The cheap version

You don’t need a second production-sized cluster. For most small-to-mid projects, a single modest VPS running the same Docker Compose stack as production, behind a subdomain like staging.yourapp.com, covers the cases above. The same docker-compose.yml that defines your production services, pointed at a smaller Postgres instance and a staging-only set of secrets, gets you 90% of the value at a fraction of the infrastructure.

# docker-compose.staging.yml
services:
  app:
    image: myapp:${TAG}
    env_file: .env.staging
    ports:
      - "3000:3000"
  db:
    image: postgres:16
    volumes:
      - staging_db_data:/var/lib/postgresql/data
volumes:
  staging_db_data:

A single small droplet or EC2 instance running this is usually a few dollars a month, and it’s the same deploy artifact (same Docker image) you’ll ship to production, which is the part that actually matters – you’re testing the thing you’ll deploy, not a hand-built approximation of it.

Data without the liability

Copying a real production database dump into staging is tempting and, for anything handling personal data, usually a bad idea – now you have customer data sitting on a lower-security box with looser access controls. Two workable alternatives: a scrubbing script that copies the schema and replaces sensitive columns with generated values (names, emails, anything identifying) before loading it into staging, or a seed script that generates realistic synthetic fixtures from scratch. The seed script is more upfront work but it’s also reusable for local development and for tests, so it tends to pay for itself.

Wiring it into your deploy flow

Staging is only useful if code actually lands there before production, which means it should be automatic, not a manual step someone forgets. A simple CI job that builds the image and deploys it to staging on every merge to your main integration branch keeps it honest:

on:
  push:
    branches: [develop]
jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp:${{ github.sha }} .
      - run: ssh staging-host "docker compose pull && docker compose up -d"

Once this exists, “check it on staging first” stops being an optional courtesy and becomes the default path something takes before reaching real users.

It’s worth protecting staging from becoming a second, unofficial production – the moment someone starts pointing a real client demo or an important internal dashboard at it, people stop feeling free to break it, and the whole point erodes. A basic HTTP auth prompt in front of the staging subdomain, or an IP allowlist if your team works from a known set of networks, keeps it clearly marked as internal without adding real infrastructure.

What to skip

Resist the urge to gold-plate staging. It doesn’t need autoscaling, high availability, or a CDN in front of it – those are production concerns, and building them twice is wasted effort. It needs to run the same code, talk to the same kinds of services, and be disposable enough that you’re not afraid to break it. If staging becomes precious – if people are scared to experiment on it – it’s stopped doing its job, which is to be the place where mistakes are cheap.

What Is Tokenization in Payments? A Plain-English Guide

Tokenization is the invisible mechanism that lets your business take card payments securely, charge customers again later without storing sensitive data, and stay on the right side of PCI compliance – all without building any security infrastructure yourself. Understanding what it is and how it works tells you which questions to ask your payment provider and which setups to avoid.

Key takeaways

  • Tokenization replaces sensitive card data with a valueless substitute string – your systems hold references to cards, not the cards themselves.
  • Unlike encryption, a token has no mathematical relationship to the original card number, so there is nothing to unscramble if your database is breached.
  • Network tokenization auto-updates stored payment methods when cards are reissued, cutting involuntary churn on subscription and recurring billing.
  • Keeping raw card data off your servers significantly shrinks your PCI DSS compliance scope and the cost of any security audit.
  • Before choosing a provider, confirm they return a usable token, support network tokenization for renewals, and hold a PCI DSS Level 1 certification.

The first time someone explains tokenization to you, it usually sounds like one of those topics you can safely ignore. Some security thing your payment provider handles in the background. Not your problem.

Then a card on file gets reissued, your subscription renewals start failing, and suddenly you’re losing customers you already won. Or your auditor asks, casually, where exactly you store card numbers – and you realise you don’t actually know. So let’s answer the obvious question first: what is tokenization, and why does it quietly decide so much about how smoothly your business takes money?

What Is Tokenization, Really?

Tokenization is the process of swapping sensitive data – usually a credit card number – for a substitute string of characters called a token. The token looks a bit like the original (it might keep the last four digits, for example), but it has no value on its own. If a thief grabs it, they can’t do anything with it. The real card number is locked away in a secure vault you don’t have to manage.

Think of it like a coat check. You hand over your coat and get a numbered tag. The tag is useless to anyone except the cloakroom that issued it. The coat never leaves the locked room.

In payments, the “coat” is the cardholder data. The “tag” is the token. Your CRM, your invoicing tool, your database – they all hold tags, not cards. When it’s time to charge the customer again, your payment provider trades the tag back in for the real card behind the scenes.

Tokenization vs Encryption – They’re Not the Same Thing

People mix these up constantly, so it’s worth being precise.

Encryption scrambles data using a key. Anyone with the right key can unscramble it back to the original – the data is hidden, but mathematically it’s still there.

Tokenization replaces the data entirely. The token has no mathematical relationship to the card number; it’s just a reference to where the real number is stored. Without access to the vault, there’s nothing to “unscramble” because the token isn’t an encrypted card. It’s a pointer to one.

Modern payment platforms use both. Encryption protects data while it’s moving across networks. Tokenization protects data while it’s sitting in your systems.

How Payment Tokenization Works in a Real Flow

Here’s what happens the first time a customer pays you online:

  1. The customer enters their card details on your checkout page.
  2. Those details go straight to your payment provider – ideally without ever touching your own servers.
  3. The provider stores the card in a secure vault and sends you back a token (something like tok_4f9b-0123).
  4. You save that token against the customer’s record.
  5. Next time you charge them – a renewal, a top-up, a one-click reorder – you send the token, not the card.

That’s the whole loop. Your database now contains references to cards, not the cards themselves. Even if someone breaches your systems, the data they walk away with isn’t useful.

There’s also a more advanced flavour of card tokenization called network tokenization, where the card networks themselves (Visa, Mastercard, and so on) issue the token. The clever part: when a customer’s physical card is reissued or expires, the network can update the token automatically. Your saved payment methods keep working without anyone having to retype card details. For subscription businesses, that single feature pays for itself many times over.

Why Tokenization Matters for Your Business

If you’ve made it this far, you might still be thinking: fine, but is this actually my problem?

Here are the practical reasons it is.

It shrinks your PCI compliance scope. Per the PCI Security Standards Council, PCI DSS applies to wherever cardholder data lives in your systems. If card numbers never touch your servers, large parts of your environment fall out of scope. That means less paperwork, cheaper audits, and less risk if anything goes wrong.

It cuts the cost of a breach to roughly nothing – at least the card-data part. You can’t lose what you don’t store. Customers, journalists, and regulators treat “tokens leaked” very differently from “card numbers leaked”.

It makes recurring revenue more reliable. Network tokens get updated automatically when cards are reissued. For any business with subscriptions, retainers, or stored payment methods, that translates directly into fewer involuntary churns.

It enables features customers actually like. One-click checkout. Save-card-on-file. Easy refunds without re-prompting for details. None of those are possible without tokens in the background.

It future-proofs omni-channel selling. If you take payments online today and want to add an app or in-person sales later, tokens travel with the customer across every channel – no re-entering cards.

How to Make Sure Your Setup Uses Tokenization Properly

You don’t need to build any of this yourself. What you do need is to confirm a few things about whoever you’re working with.

  • Are card details collected by the payment provider, not your own servers? Hosted checkout pages, payment links, and iframe-based fields keep raw card data out of your environment. If anyone is posting card numbers to your backend, that’s a red flag.
  • Do you get a token back you can safely store? A token is what lets you charge the same customer again without storing the card. Make sure the provider returns one and documents how to use it.
  • Does it support network tokenization for the schemes you accept? Crucial for recurring billing or saved cards. Ask whether tokens auto-update when cards are reissued.
  • Is the vault PCI DSS Level 1 certified? That’s the highest tier per the PCI Security Standards Council and the standard for any serious payment platform. The certification belongs to the provider – which is exactly the point.
  • Can the same token work across channels? If you might add app or in-person sales later, you want tokens that move with you, not data locked into a single product.

If any answers are fuzzy, push for clarity before you scale. Migrating tokens between providers is technically possible but operationally painful.

For deeper background, the PCI Security Standards Council publishes the official tokenization guidance, and the Wikipedia entry on tokenization (data security) gives a solid technical overview.

Putting It All Together

So, what is tokenization? The short answer: it’s the invisible trick that lets your business take cards securely, charge customers again later, and stay on the right side of compliance – without ever holding the sensitive data yourself.

The longer answer is that it quietly underpins almost every modern payment experience you actually want. One-click reorders. Subscription renewals that don’t fail when a card expires. Audits that don’t take six weeks. Checkout flows you can build without panicking about where the data lives.

If you’re rebuilding your checkout or tired of patching together a payments stack, choose a provider that treats tokenization as a default, not an upgrade. ONE Payments is built around exactly that – an all-in-one platform that lets you accept Visa, Mastercard, Amex, JCB, and UnionPay, store tokens safely, and simplify payments across every channel you sell on, the cost-effective way.

Related reading

Managing Secrets and .env Files Without Losing Your Mind

Every project I’ve worked on ends up with a .env file within the first week. Then a .env.local. Then someone adds a .env.staging and forgets to tell anyone which variables changed between it and production. Six months in, nobody is entirely sure which keys are actually still used, which ones are stale, and which ones would break the app if rotated. None of this is inevitable – it’s just what happens when secrets management is treated as an afterthought instead of a small, deliberate practice.

The baseline everyone should already have

Before anything more sophisticated: .env belongs in .gitignore, full stop. What belongs in the repo is a .env.example with every key present and placeholder or dummy values filled in – not because it’s a nice-to-have, but because it’s the only reliable documentation of what config a fresh checkout actually needs. If a new variable is added to the app, the pull request that introduces it should also update .env.example. This one habit prevents the most common onboarding failure: “it works on my machine” turning out to mean “I have three environment variables set that nobody told you about.”

The Twelve-Factor App’s config principle is still the clearest framing of why this matters: config that varies between environments (deploy-specific values, credentials) should live outside the codebase entirely, not in checked-in config files with per-environment sections.

Where things actually go wrong

The baseline above prevents accidental commits, but the real failures I see are process failures. A production API key gets pasted into a Slack thread to unblock someone quickly, and now it lives in Slack’s search index forever. A secret gets rotated in production but someone forgets staging, and staging silently starts failing auth calls a week later when a cache expires. Or the classic: a .env file does get committed, months pass, and by the time anyone notices, the key has been indexed by every automated scanner that crawls public GitHub repositories looking for exactly this.

None of these are solved by “be more careful.” They’re solved by removing the manual step where a human has to remember to be careful.

Separate config from secrets

Not everything in a .env file is equally sensitive. A feature flag, an API base URL, a log level – these are configuration, and it’s fine for them to sit in a plain file. A database password, a signing key, a third-party API secret – these need actual secret management: encrypted at rest, access-controlled, and auditable. Lumping both categories into one flat file makes it hard to reason about what’s actually sensitive.

For the second category, a dedicated secrets manager pays for itself quickly, whether that’s a cloud-native option like AWS Secrets Manager, a self-hosted HashiCorp Vault instance, or a lighter tool like Doppler or 1Password’s CLI integration. The specific tool matters less than the properties it gives you: centralized access control, an audit log of who read what and when, and the ability to rotate a value in one place instead of hunting through every environment’s config.

The OWASP Secrets Management Cheat Sheet is worth reading even if you don’t adopt every recommendation in it – the section on avoiding secrets baked into build artifacts is the one I see skipped most often. Building an API key into a Docker image at build time means that key lives in every layer of every image you ever push, including old ones sitting in a registry nobody’s looked at in a year. The safer pattern is injecting secrets at container start, through environment variables or a mounted file the entrypoint reads, so the value never touches the image itself.

Rotating without breaking things

Rotation is where most ad hoc setups fall apart, because swapping a credential atomically usually isn’t possible – there’s a window where the old value is invalid and the new one hasn’t propagated everywhere yet. The reliable pattern is dual credentials: create the new secret alongside the old one, deploy the new value everywhere it’s needed, confirm everything is using it, then revoke the old one. It’s more steps than editing one file, but it’s the difference between a rotation that’s invisible to users and one that causes an outage at 2am because a background worker still had the old database password cached.

Catching leaks before they ship

Even with good habits, mistakes happen – someone hardcodes a key while debugging and forgets to remove it before committing. A pre-commit hook running a secret scanner like gitleaks catches this before it ever reaches a shared branch, scanning the diff for patterns that look like API keys, private keys, or connection strings:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

It takes a few minutes to wire into a repo’s pre-commit config, and it has saved me from at least two accidental commits that would have otherwise required rotating a production credential under time pressure. The same scanner running again in CI, against the full history rather than just the current diff, is cheap insurance against the hook being skipped locally with --no-verify.

None of this is complicated in isolation. The hard part is doing it consistently across a team and across the lifetime of a project, which is exactly why it’s worth automating the boring parts – the example file check, the leak scan, the rotation runbook – rather than relying on everyone remembering the rules every time.

Monitoring Cron Jobs So Silent Failures Stop Surprising You

A cron job is easy to set up and easy to forget about. That’s exactly the problem. I’ve lost count of how many times a scheduled task quietly stopped working – a script started exiting early, an API it depended on changed its response shape, a disk filled up – and nobody noticed until someone asked why a report hadn’t updated in three weeks. Cron doesn’t care whether your job actually did its job. It only cares whether the process started.

Why cron failures go unnoticed

By default, cron mails job output to the crontab owner if MAILTO is set and a mail transfer agent is configured on the box. On most modern servers, neither of those things is true. No mail server, no MAILTO, so stdout and stderr just vanish. Even when mail is wired up, a script that catches its own exceptions and exits with status 0 won’t trigger anything – cron only knows the process ran, not whether the work inside it succeeded.

This is the core issue: cron monitors process execution, not job success. A script that fails halfway through, skips its actual task because an API returned an empty response, or silently no-ops because a config file is missing will look identical to a successful run from cron’s point of view.

There’s a second, quieter failure mode: overlapping runs. If a job normally finishes in two minutes but occasionally takes twenty because of a slow upstream API, and cron fires it again five minutes later regardless, you now have two instances writing to the same output file or fighting over the same database rows. Nothing crashes, but the result is subtly wrong. Wrapping the job command in flock prevents this cheaply:

*/5 * * * * flock -n /tmp/myjob.lock /usr/local/bin/myjob.sh

The -n flag makes it non-blocking – if a previous run is still holding the lock, the new invocation exits immediately instead of queuing up, which is almost always what you want for a job that’s supposed to run on a fixed schedule rather than back-to-back.

The dead man’s switch pattern

The fix that actually works is inverting the check: instead of waiting to be told something failed, expect to be told something succeeded, and alert when that confirmation doesn’t show up. This is usually called a dead man’s switch or heartbeat monitor. The job pings an external endpoint at the end of a successful run; a monitoring service watches for that ping and fires an alert if it doesn’t arrive within the expected window.

In practice this is one line added to the end of a script:

#!/bin/bash
set -euo pipefail

/usr/local/bin/generate-daily-report.sh

curl -fsS -m 10 --retry 3 https://hc-ping.com/your-check-uuid

If the report script exits non-zero, set -e stops the script before the curl line ever runs, so no ping means no success. Services like Healthchecks.io let you define the expected schedule (say, “once a day, grace period of one hour”) and will email, Slack, or page you the moment a run doesn’t check in on time. You can self-host equivalents too, but the value is in the pattern, not the specific vendor.

Structured logging and exit codes

Heartbeat pings tell you a job didn’t run cleanly, not why. Pair them with disciplined scripting: set -euo pipefail at the top of every bash script so an unhandled error actually stops execution instead of continuing on garbage state, explicit exit codes for different failure modes, and timestamped log lines so you can reconstruct what happened after the fact.

log() { echo "$(date -Is) $*" >> /var/log/myjob.log; }

log "starting export"
if ! pg_dump mydb > /tmp/export.sql; then
  log "pg_dump failed with exit $?"
  exit 1
fi
log "export finished, $(wc -l < /tmp/export.sql) lines"

This costs almost nothing to add and turns "the job silently didn't work" into "the job failed at the pg_dump step, here's the timestamp, here's the log line right before it."

A lighter self-hosted option

If you're already running Prometheus, you don't need an external SaaS for this. The node_exporter textfile collector lets any script write a metrics file that node_exporter picks up and exposes alongside normal host metrics. A cron job writes its last-success timestamp as a gauge, and an alerting rule fires if that timestamp gets too old:

echo "job_last_success_timestamp $(date +%s)" > /var/lib/node_exporter/textfile_collector/myjob.prom.$$
mv /var/lib/node_exporter/textfile_collector/myjob.prom.$$ /var/lib/node_exporter/textfile_collector/myjob.prom

The atomic rename avoids node_exporter reading a half-written file. From there, a standard Prometheus alert rule like time() - job_last_success_timestamp > 90000 covers you without adding another external dependency to your stack.

What I actually run now

For most projects I don't need Prometheus-grade infrastructure, so the practical setup is a small shell wrapper every cron job calls through, instead of calling the underlying script directly:

#!/bin/bash
set -euo pipefail
START=$(date +%s)
CMD="$1"
CHECK_URL="$2"

if $CMD; then
  DURATION=$(( $(date +%s) - START ))
  echo "$(date -Is) ok ($CMD, ${DURATION}s)" >> /var/log/cron-wrapper.log
  curl -fsS -m 10 "$CHECK_URL" > /dev/null
else
  echo "$(date -Is) FAILED ($CMD)" >> /var/log/cron-wrapper.log
fi

The crontab entry just becomes flock -n /tmp/myjob.lock /usr/local/bin/cron-wrapper.sh /usr/local/bin/myjob.sh https://hc-ping.com/uuid. It's maybe thirty lines of bash, reused across every scheduled job on a server, and it's the difference between finding out a job broke from a dashboard versus finding out from a customer.

None of this is exotic. It's the unglamorous work of treating scheduled jobs with the same seriousness as request-handling code - because a broken cron job that runs "successfully" every night for a month does just as much damage as an outage, just more quietly.

Debugging WordPress Performance: A Query Monitor Walkthrough

WordPress performance issues are annoying to diagnose because the symptoms – slow page loads, high server CPU – don’t tell you much about the cause. Is it a slow database query? A plugin making HTTP calls on every page load? PHP taking forever to render a template? Without the right tool, you’re guessing.

Query Monitor is my first stop whenever I’m investigating a slow WordPress site. It’s a free plugin that adds a debug bar to the admin toolbar with detailed breakdowns of everything that happened during the request. Here’s how I use it.

Installation and setup

Install from the WordPress plugin directory like any other plugin. Activate it and you’ll see a new toolbar item showing the page’s query count and total time. Click it and a panel opens at the bottom of the screen.

By default, Query Monitor only shows output to users who are logged in as admins. You can set a cookie to enable it for a specific session without being logged in, which is useful when you want to profile the front-end experience of a non-admin user. There’s a section in the plugin settings that explains how to set that cookie.

One thing to know before you start: Query Monitor adds some overhead itself. The numbers you see are slightly higher than what a real visitor experiences. Use it to identify relative problems – a query taking 200ms is a problem whether the baseline is 50ms overhead or not.

Reading the Queries panel

This is where most performance investigations start. The Queries panel lists every database query that ran during the request, with the SQL, duration, caller (which function called it), and component (which plugin or theme).

What to look for:

  • Duplicate queries. Query Monitor highlights these in orange. Seeing the same query run 30 times usually means something is fetching post data in a loop without caching results. Classic N+1 problem – for each post in a list, code is running a separate query to fetch related data instead of loading everything in one query upfront.
  • High duration queries. Sort by duration. Anything above 50ms is worth investigating. Look at the SQL – is there a WHERE clause without an index? Are you querying on post meta keys that aren’t indexed?
  • Unexpected callers. If a query shows up with a caller from a plugin you haven’t thought about in months, that’s a flag. Some plugins run heavy queries on every page load unconditionally.

The Queries by Component breakdown is useful for blame assignment – it groups query counts and total time by plugin or theme. I’ve found plugins responsible for 80% of queries on sites that were visibly slow.

The Hooks panel

If you’ve read up on how hooks work in WordPress, you’ll appreciate this panel. It shows every action and filter that fired during the request, how many callbacks were attached, and the total time spent in those callbacks.

This is where you find hooks doing expensive work on every request. I once found a plugin that had attached a callback to the_content filter that was running a remote HTTP request to fetch translation data – on every single page load, for every post. The Hooks panel made it immediately visible: one filter callback, 800ms duration.

You can also use the Hooks panel to understand execution order when you’re writing your own hooks. It lists priorities alongside callback names, so you can see exactly where your code is firing relative to everything else.

HTTP API Calls

WordPress has a built-in HTTP API (wp_remote_get, wp_remote_post, etc.) and Query Monitor tracks everything that goes through it. This panel shows the URL, method, response code, and duration of each outbound HTTP request.

This is often where the worst offenders hide. Plugins that check for updates on every admin page load, themes that pull in external font metrics, integrations that hit third-party APIs synchronously during the request lifecycle – they all show up here. A single blocking HTTP call can add 500ms or more to a page load if the external service is slow.

The fix is usually caching: wrap the HTTP call in a transient so it only fires once per hour (or day, depending on how fresh the data needs to be). Query Monitor helps you find the calls; fixing them is usually a matter of adding WordPress transients around the expensive operations.

Common findings and what to do about them

After running Query Monitor on a dozen slow sites, certain patterns come up repeatedly:

  1. A WooCommerce or membership plugin running 50+ queries per page. Check if object caching is enabled. Most of those queries are likely cache misses that a Redis or Memcached object cache would eliminate.
  2. A slider or gallery plugin loading 10+ scripts and stylesheets on every page, including pages that have no slider. These plugins often enqueue assets globally and only conditionally render. The fix is usually a plugin-specific setting or a filter to conditionally enqueue.
  3. An SEO plugin with a slow hook callback that rebuilds metadata on every request. Look at the PHP Errors panel too – sometimes a deprecated function warning is causing extra processing.
  4. Post meta queries without indexes. If you’re querying _wp_attached_file or custom meta keys on large tables, check whether adding a meta_key index helps. This often requires a database-level intervention.

Query Monitor won’t fix your site for you, but it removes the guesswork. Before it, I’d approach a slow WordPress site by disabling plugins one by one and timing the result – effective but time-consuming. Now I open Query Monitor, look at the three panels above, and have a reasonable diagnosis in five minutes. If you’re doing any serious WordPress development or debugging, install it before you need it.

Why I Still Use Makefiles in 2026

Every few months I see someone post about their new project scaffolding tool – some npm package that wraps common tasks, or a Justfile, or a Taskfile. I’ve tried most of them. I keep coming back to Make.

Not because Make is perfect. It has real problems I’ll get to. But because it’s already installed everywhere, has zero runtime dependencies, and everyone who’s worked on a non-trivial software project has a reasonable chance of knowing how it works.

Make as a task runner, not a build tool

Most people learn Make in the context of C/C++ compilation, where it tracks file timestamps to decide what to recompile. That’s powerful, but it’s not why I use it. I use it as a simple command runner – a documented, aliased interface to the shell commands I’d otherwise have to type or remember from a README.

The GNU Make manual is comprehensive if you want to go deep, but you don’t need most of it for this use case. You need targets, variables, and .PHONY.

Here’s a Makefile I use for a typical web project (Node backend, some Docker):

.PHONY: install dev build test lint docker-up docker-down clean

NODE_ENV ?= development

install:
	npm ci

dev:
	NODE_ENV=$(NODE_ENV) npm run dev

build:
	NODE_ENV=production npm run build

test:
	npm test

lint:
	npm run lint

docker-up:
	docker compose up -d

docker-down:
	docker compose down

clean:
	rm -rf node_modules dist .next

That’s it. Now anyone cloning this repo runs make install, make dev, make build without having to know which npm scripts exist or what flags they take. The Makefile is a discoverable interface. Run make with no arguments and you get a list of available targets (if you add a default help target – I’ll come back to that).

A few patterns I find useful

Adding a help target that parses comments keeps the interface self-documenting:

help: ## Show available targets
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

dev: ## Start local dev server
	npm run dev

build: ## Production build
	NODE_ENV=production npm run build

Now make help prints a formatted list. Small thing, but it means I don’t have to open the Makefile or a README to remember what’s available.

Variables with defaults are handy for environment-sensitive commands:

PORT ?= 3000
HOST ?= localhost

serve:
	python3 -m http.server $(PORT) --bind $(HOST)

PORT=8080 make serve overrides the default. This is simpler than writing a shell script with argument parsing.

The honest downsides

Make’s syntax is genuinely bad. Tabs-not-spaces is an infuriating requirement that has broken countless Makefiles when someone’s editor converted them. The implicit rules system is arcane. String manipulation is painful compared to any real scripting language. And the way Make interprets targets as filenames means you have to explicitly mark non-file targets with .PHONY, which is easy to forget.

Cross-platform is also a real limitation. On macOS, the default make is an old BSD version; you often need gmake for certain syntax. On Windows, you’re installing WSL or a compatibility layer. If your project needs to run on Windows machines without WSL, Makefile as task runner is probably the wrong call.

For complex pipelines – conditional logic, loops, file-generating tasks – I’ll reach for a shell script or a proper build tool. Make is not the answer to everything.

Another issue: recursive Make is a mess. If you have a monorepo and try to call $(MAKE) -C subdir from a top-level Makefile to run sub-project tasks, things get complicated fast. Dependency tracking between subdirectories doesn’t work the way you’d want. For monorepos I’d look at something else – Nx, Turborepo, or just writing explicit shell scripts. Make shines in simpler structures.

But for the common case – a list of 10-20 tasks that wrap CLI commands, used by a team comfortable with Linux – it’s hard to beat the combination of zero setup cost and universal availability. I switched away from it once for a Taskfile experiment and switched back six months later. The extra expressiveness wasn’t worth the “wait, what tool does this project use again?” friction.

API Rate Limiting Strategies: Token Bucket, Sliding Window, and When Nginx Is Enough

Rate limiting is one of those things that feels unnecessary until the day your API gets hammered by a buggy client loop or a scraper, and then it feels very necessary indeed. I’ve had both happen. The second time I decided to actually understand the options instead of just slapping something on and hoping for the best.

Why the algorithm matters

Not all rate limiters behave the same way. Two limiters with “100 requests per minute” configured can produce completely different behavior for your clients, depending on how they track and enforce that limit. The differences are most visible at burst time – when a client sends many requests in quick succession.

Token Bucket

The token bucket algorithm imagines a bucket that holds tokens. Tokens accumulate at a fixed rate up to a maximum capacity. Each request consumes one token. If the bucket is empty, the request is rejected or queued.

What this means practically: a client that’s been idle for a while gets to burst. If your bucket capacity is 20 and refill rate is 5 per second, a client that waits 4 seconds can send 20 requests at once. This is often desirable – it’s forgiving of clients that have natural spiky patterns (a user clicking around an app, for example) while still protecting against sustained abuse.

Sliding Window

The sliding window approach tracks actual request timestamps in a rolling time frame. If your window is 60 seconds and the limit is 100, the system counts requests in the 60-second period ending right now – not from the start of the current minute. This prevents the “boundary burst” problem you get with fixed windows, where a client can send 100 requests at 11:59 and another 100 at 12:00.

The downside is memory cost. You’re storing a timestamp per request per client. At scale, this adds up. A common compromise is the sliding window counter, which approximates the sliding window using two fixed windows and some math – cheaper to store, slightly less accurate but good enough for most cases.

Fixed Window

The simplest approach: count requests in the current minute (or hour, or day). Reset at the window boundary. It’s easy to implement and reason about, but that boundary burst problem is real. I wouldn’t use it for anything where timing matters to fairness.

When Nginx is enough

If you’re running an API behind Nginx and your rate limiting needs are straightforward, you probably don’t need a dedicated rate limiting library or service. Nginx’s limit_req module implements the leaky bucket algorithm (similar to token bucket), and it’s solid.

A basic config:

http {
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=api_limit burst=20 nodelay;
            limit_req_status 429;
            proxy_pass http://backend;
        }
    }
}

Breaking this down: limit_req_zone defines a shared memory zone (10MB, enough for ~160,000 IP addresses), keyed by IP, with a rate of 10 requests per second. The burst=20 allows short bursts above that rate. nodelay means burst requests are processed immediately rather than queued – without it, Nginx holds them for as long as needed to maintain the average rate, which adds latency.

You can key the zone on something other than IP – for example, a header value like an API key:

limit_req_zone $http_x_api_key zone=key_limit:10m rate=30r/s;

The full options are documented on nginx.org.

Where Nginx falls short: it doesn’t do per-user tiers (different limits for different plans), it can’t share state across multiple Nginx instances cleanly without Redis, and it has no visibility into why a request was limited. For those cases, you want something like a Redis-backed rate limiter in your application layer.

Rate limiting in application code

For anything beyond basic IP throttling, moving the logic into your application makes sense. A simple sliding window in Redis looks roughly like this in pseudocode:

function isAllowed(userId, limit, windowSeconds):
    key = "ratelimit:" + userId
    now = currentTimestampMs()
    windowStart = now - (windowSeconds * 1000)

    MULTI
        ZREMRANGEBYSCORE key 0 windowStart
        ZADD key now now
        ZCARD key
        EXPIRE key windowSeconds
    EXEC

    count = result[2]
    return count <= limit

The sorted set stores request timestamps as both score and member. You clean up old entries, add the new one, count what’s left. It’s atomic via MULTI/EXEC. This is exactly what libraries like rate-limiter-flexible (Node.js) implement under the hood.

External APIs and rate limits you don’t control

It’s worth mentioning the other side of this: when you’re the client, not the server. A lot of APIs – messaging platforms, maps, payment providers – have their own rate limits you have to respect. If you’ve done any payment API integration, you’ve probably hit this already. Most payment gateways throttle API calls per second and per day, sometimes differently for test vs. production environments.

When I’m integrating an external API, I treat rate limit headers (X-RateLimit-Remaining, Retry-After) as first-class response data. If the API sends them, read them and back off accordingly. If it doesn’t – and some older APIs don’t – build in a conservative retry with exponential backoff. Before getting into the details of any specific integration, it helps to think through choosing an API style that fits your use case, since REST, GraphQL, and gRPC have different patterns for handling these limits.

For most small-to-medium projects, Nginx’s limit_req handles the abuse prevention side, and a light Redis-based limiter handles per-user logic. You don’t need a dedicated rate limiting service until your traffic gets complex enough that the overhead justifies it.