DEV Community

Max
Max

Posted on • Originally published at quickshrink.orthogonal.info

How to Compress Images From the Command Line (and in CI) — No Upload, No Account

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:

  1. They don't script. You can't put "open a browser and drag files" in a package.json or a GitHub Action.
  2. They upload. For a lot of teams, shipping customer/product images to a third-party server is a non-starter.
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Recurse into subfolders and keep the structure:

npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./assets -o ./out --recursive
Enter fullscreen mode Exit fullscreen mode

Preview before you touch anything:

npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./photos --dry-run
Enter fullscreen mode Exit fullscreen mode

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%)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)