A self-hosted, privacy-first image processing microservice written in Go. Lightweight open-source alternative to Cloudinary/imgix - no SaaS fees, no vendor lock-in, full data control.
- Resize with
fit,fill,stretchmodes - Convert between JPEG / PNG / WebP / GIF / AVIF
- Optimize via optional external binaries (
pngquant,mozjpegcjpeg,cwebp,avifenc) - Info — EXIF + image metadata extraction
- Health check for orchestrators
- Process — URL-parameter API with HMAC-signed URLs for direct
<img src>usage (v1.1, signed v1.1.1) - Thumbnail — fixed-size center-crop (v1.1)
- API key / bearer-token auth + per-IP rate limiting (v1.1)
- HMAC-signed URLs for
/process(v1.1.1) X-Processing-Timeresponse header (v1.1)- Layered config: code defaults → optional YAML → env vars
- Structured
slogrequest logging - Stateless, horizontally scalable, Docker-ready
git clone https://github.com/DulanDev/GoImager.git
cd GoImager
go mod tidy
go run cmd/server/main.goServer listens on http://localhost:8080 by default. Interactive API docs
(Swagger UI) are served by the running binary at
http://localhost:8080/docs — no external
tooling required. Raw spec: /openapi.yaml (YAML) or /openapi.json (JSON).
The OpenAPI 3.0.3 spec lives at api/openapi.yaml and is
embedded into the binary at build time. At runtime:
/docs— interactive Swagger UI (vendored, served offline)./openapi.yaml— raw spec,Content-Type: application/yaml./openapi.json— JSON conversion (Accept: application/jsonor/openapi.json).
OpenAPI docs are public — they bypass API_KEY and signed-URL auth, just
like /health. The Swagger UI assets are bundled in the binary (~3 MB) so
GoImager keeps working fully offline (matches the project's
"offline support: Yes" positioning).
The spec is checked with Spectral against the standard OAS ruleset:
npx @stoplight/spectral-cli@6.14.1 lint api/openapi.yamlConfiguration lives in .spectral.yaml. The operationId
rule is disabled — the spec relies on path + method combinations as
canonical operation identities.
docker compose up --buildThe runtime image bundles pngquant, mozjpeg and libwebp-tools, so full
optimization works out of the box.
brew install pngquant mozjpeg webp # macOS
# or
sudo apt-get install -y pngquant mozjpeg webp # Debian/UbuntuWhen a tool is missing the service logs a warning and falls back to Go's native re-encode (metadata stripped, no quantization / Huffman optimization).
All non-binary errors return structured JSON:
{ "error": "description of what went wrong", "code": "INVALID_DIMENSIONS" }{ "status": "ok", "version": "1.2.0" }multipart/form-data, field image → metadata, no transformation.
{
"width": 1920,
"height": 1080,
"format": "jpeg",
"size_bytes": 204800,
"color_model": "YCbCr",
"exif": {
"camera": "Sony A7 IV",
"taken_at": "2024-08-15T14:32:00Z",
"gps": null
}
}| Field | Type | Required | Description |
|---|---|---|---|
image |
file | yes | Image to resize |
width |
integer | yes* | Target width in pixels. 0 = auto-scale |
height |
integer | yes* | Target height in pixels. 0 = auto-scale |
mode |
string | no | fit (default), fill, stretch |
format |
string | no | Output: jpeg, png, webp, gif |
quality |
integer | no | Compression quality 1–100 (default: 85) |
*At least one of width / height must be non-zero. When format is omitted
the input format is preserved.
Returns the transformed image binary with the matching Content-Type.
| Field | Type | Required | Description |
|---|---|---|---|
image |
file | yes | Image to convert |
format |
string | yes | Target: jpeg, png, webp, gif, avif |
quality |
integer | no | Compression quality 1–100 (default: 85) |
| Field | Type | Required | Description |
|---|---|---|---|
image |
file | yes | Image to optimize |
quality |
integer | no | Target quality 1–100 (default: 80) |
strip_exif |
boolean | no | Remove EXIF metadata (default: true) |
format |
string | no | Output override (default: same as input) |
Headers: X-Original-Size, X-Optimized-Size, X-Reduction-Percent.
Compression strategy per format:
| Input format | Tool / method |
|---|---|
| PNG | pngquant (24-bit → 8-bit indexed) |
| JPEG | mozjpeg cjpeg (Huffman optim) |
| WebP | cwebp |
| GIF / other | Go decode + re-encode fallback |
All paths strip EXIF on decode.
URL-parameter API. Fetches a remote src image and returns the transformed
binary, ready for direct use in <img src="...">.
| Param | Type | Description |
|---|---|---|
src |
string | URL of the source image (URL-encoded) |
w |
integer | Target width |
h |
integer | Target height |
mode |
string | fit (default), fill, stretch |
format |
string | jpeg, png, webp, avif |
q |
integer | Quality 1–100 |
blur |
float | Gaussian blur radius (e.g. 1.5) |
sharp |
float | Sharpen sigma (e.g. 1.0) |
rotate |
integer | 90, 180, 270 |
flip |
string | h (horizontal) or v (vertical) |
exp |
integer | (signed mode) Expiry as Unix timestamp. Required when SIGNING_KEY is set. |
sig |
string | (signed mode) HMAC-SHA256 hex signature. Required when SIGNING_KEY is set. |
Example (unsigned, dev only — SIGNING_KEY unset):
GET /process?src=https%3A%2F%2Fexample.com%2Fphoto.jpg&w=800&format=webp&q=80
Example (signed):
GET /process?src=https%3A%2F%2Fexample.com%2Fphoto.jpg&w=800&format=webp&q=80&exp=1700000000&sig=5f2c...
The src host must appear in the ALLOWED_DOMAINS allowlist (* allows any).
When SIGNING_KEY is set, every /process URL must carry exp + sig;
expired → 410 Gone, bad/missing signature → 401. See
Authentication for signing code.
Fixed-size center-crop thumbnail.
| Field | Type | Required | Description |
|---|---|---|---|
image |
file | yes | Source image |
width |
integer | yes | Thumbnail width |
height |
integer | yes | Thumbnail height |
format |
string | no | Output format (default: webp) |
quality |
integer | no | Quality 1–100 (default: 85) |
GoImager supports two independent auth mechanisms, both off by default so the service runs open on a trusted private network or behind a reverse proxy. Enable either or both.
| Mechanism | Protects | When enabled | Failed request |
|---|---|---|---|
Bearer API key (API_KEY) |
POST endpoints (/resize, /convert, /optimize, /thumbnail, /info) |
API_KEY env/YAML set non-empty |
401 UNAUTHORIZED |
HMAC-signed URLs (SIGNING_KEY / SIGNING_KEYS) |
GET /process only |
SIGNING_KEY(S) set non-empty |
401 UNAUTHORIZED / 410 EXPIRED |
/health and / are always open. /process is exempt from the Bearer check
— its protection lives in signing. Both secrets default to empty = service
open (intended for private networks where the network is the auth).
- POST endpoints are called server-to-server — your backend talks to GoImager over a private network. A static Bearer key in env is the right shape.
/processis browser-direct — your backend renders<img src="https://img.example.com/process?...">tags that browsers load. A static secret shipped in HTML would leak. Signing keeps the secret server-side; only its (time-bound) signature travels to the client.
This mirrors imgproxy (IMGPROXY_KEY/SALT for signed URLs, plus a separate
IMGPROXY_SECRET for the Authorization header) and thumbor (HMAC in the URL
path, single SECURITY_KEY).
- Take the query string, drop the
sigparameter (if present). - Sort remaining parameters by key lexicographically (stable; repeated keys
sort by value). Encode each key/value as
application/x-www-form-urlencoded(spaces are+, not%20— matching Go'snet/url.Values.Encode()); join with&. - The string to sign is
/process?+ that canonical query. sig = hex(HMAC_SHA256(key, string_to_sign)).- Append
&sig=<sig>to the URL.expis a normal param and must be part of the signed string (it is not stripped — onlysigis).
SIGNING_KEYS (comma-separated list) takes precedence over SIGNING_KEY
(single value; checked in order), enabling zero-downtime rotation: deploy
with old,new, switch clients to new, then redeploy with just new.
Server validation: missing sig/exp → 401; exp in the past → 410;
signature mismatch → 401 (no detail leaked). On success, normal
ALLOWED_DOMAINS enforcement applies to src.
CDN caveat: headers aren't signed. CDNs must cache
/processon the full URL (incl.sig+exp) to avoid bypassing expiry by header manipulation — same caveat as imgproxy.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"strconv"
"time"
)
// signProcessURL appends exp + sig to q and returns the canonical /process
// query string. `key` must match the server's SIGNING_KEY or one of SIGNING_KEYS.
func signProcessURL(key string, q url.Values) string {
q.Set("exp", strconv.FormatInt(time.Now().Add(24*time.Hour).Unix(), 10))
q.Del("sig")
canonical := q.Encode() // url.Values.Encode sorts keys and encodes spaces as '+'
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte("/process?" + canonical))
q.Set("sig", hex.EncodeToString(mac.Sum(nil)))
return q.Encode()
}encodeURIComponent alone encodes space as %20, but Go's canonical form
uses + — the formEncode helper below normalizes accordingly. Using this
helper is the difference between signatures that verify and ones that don't.
const crypto = require("crypto");
function formEncode(s) {
return encodeURIComponent(s).replace(/%20/g, "+");
}
function signProcessURL(key, params /*: URLSearchParams */) {
const exp = String(Math.floor(Date.now() / 1000) + 86400); // 24h
params.set("exp", exp);
params.delete("sig");
const entries = [...params.entries()].sort((a, b) =>
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
);
const canonical = entries
.map(([k, v]) => `${formEncode(k)}=${formEncode(v)}`)
.join("&");
const sig = crypto
.createHmac("sha256", key)
.update(`/process?${canonical}`)
.digest("hex");
params.set("sig", sig);
return params.toString();
}The repo bundles a CLI generator that mirrors internal/sign exactly:
go run ./cmd/gensig -key=$SIGNING_KEY -exp=24h \
-base=http://localhost:8080 \
'src=https://example.com/photo.jpg&w=800&format=webp&q=80'
# -> http://localhost:8080/process?exp=...&format=webp&sig=...&src=...&w=800Useful for api.http / curl during local dev; paste the output straight into
a request.
Precedence (low → high): code defaults → YAML file → environment variables.
YAML is searched in order: ./goimager.yaml, $HOME/.goimager.yaml,
/etc/goimager/config.yaml. See goimager.example.yaml.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
MAX_FILE_SIZE_MB |
20 |
Max upload size in MB |
MAX_DIMENSION |
10000 |
Max allowed image dimension |
DEFAULT_QUALITY |
85 |
Default compression quality |
API_KEY |
(empty) | If set, POST endpoints require Authorization: Bearer <key>. Empty = open (private-network mode). |
SIGNING_KEY |
(empty) | If set, /process URLs must carry sig + exp. See Authentication. |
SIGNING_KEYS |
(unset) | Optional comma-separated list for zero-downtime rotation; checked in order. Takes precedence over SIGNING_KEY. |
RATE_LIMIT_RPS |
100 |
Requests per second per client IP |
RATE_LIMIT_RPM |
(unset) | Per-minute limit; overrides RPS when set |
ALLOWED_DOMAINS |
* |
Comma-separated allowlist for /process?src= |
LOG_LEVEL |
info |
debug / info / warn / error |
LOG_FORMAT |
json |
json or text |
OPTIMIZER_PNGQUANT_PATH |
pngquant |
Path to pngquant binary (empty = skip) |
OPTIMIZER_MOZJPEG_PATH |
cjpeg |
Path to mozjpeg cjpeg binary (empty = skip) |
OPTIMIZER_CWEBP_PATH |
cwebp |
Path to cwebp binary (empty = skip) |
OPTIMIZER_AVIF_PATH |
avifenc |
Path to avifenc binary (empty = skip AVIF) |
go mod tidy
go run cmd/server/main.go
go test ./... -coverprofile=coverage.out
go tool cover -func=coverage.out
go vet ./...MIT — see LICENSE.
