Most "compress your images" advice ends with "...now drag your files into this website." That's fine for a one-off. It's useless when you have a /public/images folder with 300 PNGs, or a build step that should never ship a 4 MB hero image again.
I wanted image compression that lives where the rest of my tooling lives: the terminal and CI. No upload, no account, no clicking. Here's the workflow I landed on, plus a tiny CLI I built to make it one command.
The problem with web-based compressors in a dev workflow
TinyPNG, Squoosh, and friends are great tools. But in a real project they have three issues:
-
They don't script. You can't put "open a browser and drag files" in a
package.jsonor a GitHub Action. - They upload. For a lot of teams, shipping customer/product images to a third-party server is a non-starter.
- They're one-at-a-time-ish. Batch + recursive folders + keeping structure is exactly the boring part you want automated.
What you actually want: compress ./images → done, locally, every time.
Option 1: raw sharp in a script
If you just want the engine, sharp (libvips bindings) is the workhorse. A minimal batch script:
// compress.js
import sharp from "sharp";
import { glob } from "glob";
import path from "node:path";
import fs from "node:fs/promises";
const files = await glob("images/**/*.{jpg,jpeg,png}");
await fs.mkdir("dist", { recursive: true });
for (const file of files) {
const out = path.join("dist", path.basename(file, path.extname(file)) + ".webp");
await sharp(file)
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(out);
console.log("✓", out);
}
This works. But you'll quickly want flags (quality, format, max-width), parallelism across cores, "don't enlarge," metadata stripping, dry-run, and preserved folder structure — and now you're maintaining a tool instead of shipping your app.
Option 2: a tiny CLI that already does all that
So I packaged exactly that into a small, MIT-licensed CLI called QuickShrink. It's a thin, well-tested wrapper over sharp, focused on the batch-folder workflow. Run it once with npx, no global install:
# compress every image in ./images → ./compressed
npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./images
Convert a whole folder to WebP and cap the width for web (the single most impactful thing you can do for page weight):
npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./photos \
-o ./web --format webp --max-width 1600 --quality 80
Recurse into subfolders and keep the structure:
npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./assets -o ./out --recursive
Preview before you touch anything:
npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./photos --dry-run
Typical output:
✓ out/hero.webp 1.38 MB → 42.7 KB (-97%)
✓ out/sub/banner.webp 3.10 MB → 31.1 KB (-99%)
Done: 2 ok, 0 failed.
Total: 4.47 MB → 73.8 KB (saved 4.40 MB, 98.4%)
Flags it supports:
| Flag | Does |
|---|---|
-o, --out <dir> |
Output directory (default ./compressed) |
--format <fmt> |
jpeg \ |
--quality <1-100> |
Encoder quality (default 80) |
--max-width / --max-height
|
Resize down, never up |
--recursive |
Walk subfolders |
--dry-run |
Show the plan, write nothing |
Everything runs locally — your images never leave the machine. It uses all your CPU cores and strips metadata by default.
Option 3: wire it into CI
Because it's a single command, dropping it into a GitHub Action is trivial. Here's a step that compresses everything under public/images and fails loudly if compression errors:
# .github/workflows/images.yml
name: Compress images
on:
pull_request:
paths: ["public/images/**"]
jobs:
shrink:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- name: Compress images
run: |
npx -y https://quickshrink.orthogonal.info/cli/quickshrink.tgz \
./public/images -o ./public/images --format webp --max-width 1600
- name: Commit if changed
run: |
git config user.name "image-bot"
git config user.email "bot@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "chore: compress images"
git push
Now nobody on the team can accidentally ship a 4 MB screenshot again. The bot quietly WebP's and resizes on every PR that touches images.
A package.json shortcut
For local use, alias it so teammates don't need to remember the URL:
{
"scripts": {
"images": "npx -y https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./src/assets -o ./public/img --format webp --max-width 1600"
}
}
npm run images and you're done.
What about serverless / "I can't install native deps"?
sharp ships prebuilt binaries, so the CLI works in most CI runners and locally. The one place it gets awkward is constrained serverless functions or runtimes where native libs are a pain. For that case I'm building a small hosted compression API (key-based, metered) so you can POST an image and get bytes back without bundling libvips. It's in private beta — if that's your use case, there's a note + email on the CLI page and I'd genuinely like the feedback on what limits/pricing make sense.
Prefer a GUI for one-offs?
For the occasional "just shrink this one screenshot" moment, there's a browser version that does the same thing client-side (the compression runs in your browser via Canvas — also no upload). But for anything repeatable, the CLI is the move.
TL;DR:
npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./images --format webp --max-width 1600
Local, scriptable, batch, free, MIT. That's the whole pitch. If you put it in CI, I'd love to hear how it goes — and what flag you wish it had next.
Top comments (0)