A fast, self-hostable web app that turns one image into every size and format you actually need — web formats, social media presets, and a complete favicon pack — and hands them back as a single ZIP.
Drop your images in the browser, tick the targets you want, and download the optimized results. No accounts, no uploads kept on disk, no setup beyond running one Docker container.
Preparing images for the web is repetitive: resize for Instagram, crop for an Open Graph card, generate a dozen favicon files, re-encode to WebP/AVIF for performance… usually across several tools. This does all of it in one drag-and-drop step, runs entirely on your own server, and never writes your images to disk.
- Many targets at once — pick any combination of presets; each image is optimized for every selected target in parallel.
- Modern + classic formats — outputs WebP, AVIF, JPEG (progressive), and PNG.
- Wide input support — JPEG, PNG, WebP, AVIF, and iPhone HEIC/HEIF.
- Drop-in favicon pack — one click produces
favicon.ico, all the PNG sizes,apple-touch-icon,site.webmanifest, and a ready-to-paste HTML snippet. - Social presets — Instagram, LinkedIn, X, Facebook, Pinterest, Open Graph, plus email/web banners.
- Live progress — per-target progress streamed over Server-Sent Events.
- Privacy by design — images are processed in memory only and deleted right after download (or after a short timeout); nothing is stored.
- Single container — the Svelte UI is embedded into the Go binary, so the whole app is one small Docker image with no external services.
- Add images — drag & drop or browse (JPEG, PNG, WebP, AVIF, HEIC).
- Pick targets — choose one or more presets.
- Optimize — each image is resized/re-encoded for every target; watch live progress.
- Download — get a single ZIP with every optimized variant, named by preset.
Run the prebuilt-style image locally (only Docker required):
docker build -t image-optimizer .
docker run -p 3000:3000 image-optimizerThen open http://localhost:3000 and start dropping images. See Production image for deployment details and Development for the hot-reload setup.
- Go + Fiber v3 — single compiled binary, low memory footprint
- govips / libvips — the image processing engine
- Svelte + Vite — zero-runtime SPA, embedded via
//go:embed - Single Docker container — Fiber serves both the API and the SPA
.
├── main.go # Fiber app, config, graceful shutdown, embeds frontend/dist
├── config/ # env-var configuration (port, limits, workers, job TTL)
├── handlers/ # HTTP handlers: health, upload, progress (SSE), download
├── processor/ # govips pipeline, worker semaphore, presets
├── frontend/ # Svelte + Vite app; dist/ is the go:embed target
├── Dockerfile # 3-stage build: Vite → Go (cgo+libvips) → debian-slim
└── docker-compose.yml # local dev with hot-reload
Note on
frontend/dist: a small stubindex.htmlis committed sogo buildworks locally without first running Vite. The Docker frontend stage overwrites it with the real Vite build.
Input: JPEG, PNG, WebP, AVIF, and HEIC/HEIF (iPhone photos). HEIC/HEIF are
decoded via libvips' heifload (libheif/libde265, bundled in the Docker images).
The convert_* presets are the "just turn this into a usable file" path:
faithful, high-quality format conversion at the original size (no crop). Handy for
turning an iPhone HEIC straight into JPEG/PNG/WebP/AVIF without any other tool.
Output presets (registry: processor/preset.go):
| Preset | Format | Dimensions | Notes |
|---|---|---|---|
convert_jpeg |
JPEG | original | quality 92, progressive — faithful conversion |
convert_png |
PNG | original | compression 6 (lossless) |
convert_webp |
WebP | original | quality 90 — faithful conversion |
convert_avif |
AVIF | original | quality 80, effort 4 — faithful conversion |
compress_best |
source | original | re-encode only — near-lossless, keeps the source format |
compress_balanced |
source | original | re-encode only — strong shrink, great quality |
compress_max |
source | original | re-encode only — aggressive (PNG uses a lossy palette when it helps) |
website_webp |
WebP | original | quality 80 (web-optimized) |
website_avif |
AVIF | original | quality 60, effort 4 (web-optimized) |
jpeg_original |
JPEG | original | quality 80, progressive |
png_original |
PNG | original | compression 6 |
instagram_square |
JPEG | 1080×1080 | progressive |
instagram_portrait |
JPEG | 1080×1350 | progressive |
instagram_story |
JPEG | 1080×1920 | progressive |
linkedin |
JPEG | 1200×627 | progressive |
twitter |
JPEG | 1200×675 | progressive |
facebook_post |
JPEG | 1200×630 | progressive |
pinterest_pin |
JPEG | 1000×1500 | progressive |
og_image |
PNG | 1200×630 | compression 6 |
favicon |
— | — | favicon pack (see below) |
thumbnail |
PNG | 400×400 | compression 6 |
email_header |
JPEG | 600×200 | progressive |
web_banner |
JPEG | 1920×480 | progressive |
Fixed-size presets center-crop to the target dimensions; *_original presets
keep the source dimensions and just re-encode + strip metadata.
The compress_* presets are the "just make this smaller for the web" path:
they keep the source format (JPEG→JPEG, PNG→PNG, WebP→WebP; HEIC→JPEG, SVG→PNG)
and the original dimensions, tuning only the encoding across three honest tiers
(best / balanced / max savings). PNG savings are modest — it has no quality
knob — so only compress_max tries a lossy palette, and only keeps it when it
actually comes out smaller.
The favicon preset is a multi-file output: instead of one image it generates a
complete drop-in icon set, bundled under a favicon/ folder in the ZIP:
favicon.ico(multi-size 16/32/48, hand-built ICO container)favicon-16x16.png,favicon-32x32.png,favicon-48x48.pngapple-touch-icon.png(180×180)android-chrome-192x192.png,android-chrome-512x512.pngsite.webmanifestand aREADME.txtwith the exact<head><link>snippet
The pack is generated from a center-cropped square master. The multi-file plumbing
lives in Preset.Kind / Result.Files (processor/preset.go,
processor/favicon_vips.go, processor/ico.go).
Hot-reload for both backend (Air) and frontend (Vite dev server):
docker compose up- Backend: http://localhost:3000 (
/healthreturns{"status":"ok"}) - Frontend dev server: http://localhost:5173
The single self-contained image (Vite build embedded into the Go binary):
docker build -t image-optimizer .
docker run -p 3000:3000 image-optimizer
curl http://localhost:3000/health # -> 200 {"status":"ok"}The final image contains only the Go binary plus the libvips runtime libs
(libvips42) on debian:bookworm-slim, and runs as a non-root user.
The image ships a Docker HEALTHCHECK that probes GET /health. The binary
self-probes via its -healthcheck flag, so no curl/wget is needed in the
minimal runtime image:
docker run -d -p 3000:3000 image-optimizer
docker ps # STATUS shows "healthy" once the start period elapsesOn SIGINT/SIGTERM the server stops accepting new connections, drains
in-flight jobs (so their SSE clients receive a terminal event and the ZIP stays
briefly downloadable), tears down libvips, then exits. Each phase is bounded by a
30s timeout so shutdown never hangs.
Job state lives in memory only — there is no disk temp storage. A job is freed
when its ZIP is downloaded, or by a background reaper after JOB_TTL_MINUTES
(default 10), whichever comes first. This bounds memory for jobs that are never
downloaded.
All configuration is via environment variables, read once at startup (the resolved values are logged). Invalid or non-positive numeric values fall back to the default rather than failing startup.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
TCP port the HTTP server listens on. |
MAX_FILE_SIZE_MB |
50 |
Per-file upload cap. Larger files are rejected with 400. |
WORKER_COUNT |
number of CPUs | Max concurrent libvips pipelines (the real concurrency limit). |
JOB_TTL_MINUTES |
10 |
How long a job's in-memory state is retained before the reaper frees it. |
The whole-request multipart body limit is derived from MAX_FILE_SIZE_MB plus
headroom for multiple files and multipart boundaries.
- Go 1.26+ · Node 22+ · Docker
- libvips ≥ 8.14 (provided inside the Docker images; install locally only if building the Go binary outside Docker)
Released into the public domain under The Unlicense — do whatever you want with it.