# imageproxy: a Go image proxy whose URL format is the whole API

> willnorris/imageproxy resizes, crops and caches images for an arbitrary remote URL, and everything it can do is expressed as comma-delimited options in the path. The interesting decisions are in the encoding rules, the access control model and a v0.13.0 security fix.

**willnorris/imageproxy** — A caching, resizing image proxy written in Go

- Repository: https://github.com/willnorris/imageproxy
- Stars: 3,989 · Forks: 511
- Language: Go
- License: Apache-2.0
- Published: 2026-10-08 · Updated: 2026-10-08 · Language: en
- Canonical page: https://hysenlabs.com/projects/willnorris-imageproxy

## The URL is the interface

The README states the URL form in one line and everything else follows from it:

```
http://localhost/{options}/{remote_url}
```

The remote URL is the remainder of the path. Options are a comma-delimited list that can be supplied in any order, and duplicates overwrite previous values rather than stacking, so `200x,200x` resolves to the same as `200x`. That single property makes the option set composable in a way that a query-string API with repeated keys is not, and it means you can build a proxy URL by string concatenation without worrying about ordering.

The examples table works through the shapes that matter, all against a source image measuring 1024 by 678 pixels. `200x` gives 200 pixels wide with proportional height. `x0.15` gives 15% of the original height with proportional width, so you can size by either axis and leave the other free. `100x150` gives both dimensions, cropping as needed to hit them. `100` alone is a square crop at 100 pixels. `150,fit` scales to fit a 150 pixel square without cropping. `100,r90` rotates 90 degrees. `100,fv,fh` flips horizontally and vertically.

The distinction between `100` and `150,fit` is the one that generates the most support questions. A bare dimension crops to fill; `fit` fits the whole image inside the box. Getting that wrong on a photo with a subject near the edge is how you end up with a cropped head, so it is worth reading the table rather than guessing.

The complete option list is not in the README. It points at `ParseOptions` in the package documentation at pkg.go.dev, which is where to go for the full set covering cropping, resizing, rotation, flipping and digital signatures.

## Three encodings for the remote URL, and the query string trap

The remote URL may be plain text with no encoding, percent-encoded, or base64 encoded using the URL-safe alphabet with no padding. Each has different behaviour around query strings, and that difference is the most likely source of a bug in an integration.

With no encoding, any query string on the proxy URL is treated as part of the remote URL. The README's example: given the proxy URL `http://localhost/x/http://example.com/?id=1`, the remote URL is `http://example.com/?id=1`. That is convenient and it means you cannot add your own parameters to a proxy URL without them being forwarded.

With percent-encoding, the full URL must be encoded, any query string on the proxy URL is not part of the remote URL, and the encoded value must be an absolute URL rather than a relative one resolved against a default base. So `http://localhost/x/http%3A%2F%2Fexample.com%2F%3Fid%3D1`.

With base64 encoding, the full URL must also be encoded, a proxy-level query string is again excluded, and relative URLs are permitted because a default base URL can resolve them. So `http://localhost/x/aHR0cDovL2V4YW1wbGUuY29tLz9pZD0x`.

Both encodings were added in v0.13.0, in September 2025, as two separate changes: allow base64 encoding the remote URL, and allow URL encoding the remote URL. Before that release there was one way to do it, and if you are integrating against an older deployment the base64 form will simply not parse.

The practical guidance follows from the rules. Use plain form when the remote URL has no query string and reads cleanly. Use percent-encoding when the remote URL has a query string you need preserved. Use base64 when the URL is awkward to encode or you want relative URLs resolved against a configured base.

## Access control is an allowed-hosts list or HMAC signing

A proxy that fetches any URL you hand it is an open proxy and a denial-of-service amplifier, so access control is not optional in this design. The README lists it as a feature: access control using an allowed hosts list or request signing with HMAC-SHA256.

The allowed-hosts list is the simple form and it is right for the author's own described use case, resizing images hosted on a site he controls. You enumerate the hosts you will fetch from and the proxy refuses everything else. The weakness is that anyone who can reach the proxy can request a resized image from any of your allowed hosts, at any size.

Request signing closes that. The client computes an HMAC-SHA256 over the request and the proxy recomputes it, so only holders of the shared secret can make the proxy fetch anything. That is what makes the second documented use case possible: the README says you can enable request signing and use it as an SSL proxy for remote images, similar to `atmos/camo` but with additional image adjustment options.

The camo comparison is the useful framing. A camo proxy exists so that images embedded from arbitrary origins load over your own domain, which fixes mixed-content problems in a page served over HTTPS. Doing that with a plain image resizer would mean allowing every host on the internet, which is exactly the hole signing closes. The resizing capability is the addition camo does not have.

So there are two distinct deployments here, and they have different security requirements. Serving your own images needs a hosts allowlist. Serving other people's images to your users needs signing, or an open proxy that anyone can aim at a third party.

## Six cache backends in one binary, and two flags added in v0.13.0

Caching is the reason to run a proxy at all rather than resizing on the fly, and the README lists the backends as in-memory, on disk, or with Amazon S3, Google Cloud Storage, Azure Storage, or Redis. All of it ships in the same static binary.

The choice is a startup flag rather than a config file, which is the project's consistent style. The container entrypoint makes that concrete:

```dockerfile
FROM cgr.dev/chainguard/static:latest
COPY --from=build /app/imageproxy /app/imageproxy
CMD ["-addr", "0.0.0.0:8080"]
ENTRYPOINT ["/app/imageproxy"]
EXPOSE 8080
```

That also tells you the deployment story: the build stage uses `cgr.dev/chainguard/wolfi-base` with `go-1.26` installed, builds with `CGO_ENABLED=0` for the target platform, and the runtime stage copies the resulting binary into `cgr.dev/chainguard/static:latest`. No libc, no shell, no runtime package manager. The Go module is `willnorris.com/go/imageproxy` at go 1.25.8.

Two cache flags arrived in v0.13.0 and they are worth understanding because they change semantics rather than adding capability. The first allows overriding cache directives in responses, the second adds a `-forceCache` flag to override `no-store` and `private` directives. That is a deliberate override of what the origin server asked for, which is what you want when your origin sets `no-store` on images that never change and you would otherwise re-fetch them forever, and what you must not want for an origin where the URL does not uniquely identify the content.

The dependencies show how the backends are implemented: `github.com/aws/aws-sdk-go` and `cloud.google.com/go/storage` for S3 and GCS, `github.com/PaulARoy/azurestoragecache` and the Azure autorest modules for Azure, `github.com/gomodule/redigo` for Redis, `github.com/peterbourgon/diskv` for disk, `github.com/die-net/lrucache` for memory, and `github.com/gregjones/httpcache` for conditional-request handling. `github.com/prometheus/client_golang` is there for metrics.

## Formats, the pixel flooding fix, and the release gap

Format support is jpeg, png, webp for decode only, tiff, and gif including animated gifs. The webp restriction is the one to know: the proxy will read a webp image and resize it, and the output will not be webp, so if your origin serves webp you are accepting a format conversion on the way through.

The image work is done by `github.com/disintegration/imaging` for resizing, `github.com/muesli/smartcrop` for content-aware cropping, `willnorris.com/go/gifresize` for animation, and `golang.org/x/image` plus `github.com/rwcarlsen/goexif` for decoding and metadata. Animated gif support means the proxy has to decode and re-encode every frame, which is meaningfully more expensive than resizing a still image.

That expense is exactly what the v0.13.0 security fix is about. The release lists a security fix to prevent a pixel flooding attack, credited to blakestoddard as a direct commit rather than a pull request. A pixel flooding attack is a small compressed image that decodes into an enormous bitmap, so a proxy that decodes untrusted images without a bound on total pixels will allocate far more memory than the request deserves. If you run this on untrusted input, a pre-2025-06-29 tag is the version to avoid.

The release cadence has an unusual shape. `v0.11.2` on 2021-10-13 only moved Docker images to GitHub Package Registry as `ghcr.io/willnorris/imageproxy`. Then `v0.12.0` on 2025-04-18 describes itself as the first release in over three years, though with not too much changed: dependency updates, a MaxRedirects option added by a contributor, a Caddy module added by the author, and an updated `imageproxy.service` unit file. Then `v0.13.0` on 2025-06-29, about ten weeks later, with the encoding options, the cache overrides, the security fix, and a fix to return a more complete 304 from the transforming transport, contributed by flavorjones.

A three-year gap followed by two releases ten weeks apart is a specific kind of project. The code gets updated when someone needs something, and the 0.x version line means the author does not promise interface stability.

## How the author actually runs it, and what that implies

The v0.12.0 release notes contain a sentence worth more than a feature list: a new Caddy module was added, and the note says it is actually the only way the author runs imageproxy now. That is a strong recommendation from the person who wrote the flags.

Running as a Caddy module changes the operational shape. Caddy terminates TLS and serves the proxy from inside the same process, so there is no separate container to run, no port to expose, and no second TLS configuration to keep in sync. The repository has a `caddy/` directory at the root for it. For a service whose main job is rewriting image URLs in a site you already serve, that removes most of the work.

The `internal/` directory and the root-level `cache.go`, `data.go`, `imageproxy.go`, `metrics.go` and `transform.go`, each with a matching `_test.go`, describe a library with a clean separation: caching, fetching, image transformation and metrics are separable concerns rather than one handler. There is an `etc/` directory, a `docs/` directory and a `third_party/` directory, so configuration examples and vendored code have homes.

Two deployment details from earlier releases still apply. From v0.11.2, Docker images are published exclusively to GitHub Package Registry, so the image name is `ghcr.io/willnorris/imageproxy` and the old `willnorris/imageproxy` name no longer receives updates. From v0.12.0, the `imageproxy.service` systemd unit was updated, which matters if you deploy to a plain Linux host rather than a container.

The README also states a support policy: compatibility with the two most recent major Go releases, with a separately tracked minimum that currently sits at go1.18 and might change at any time. The tested versions are listed in `.github/workflows/tests.yml`, so the matrix is public rather than implied.

The `Module` line in the manifest and the pkg.go.dev reference in the README badges mean you can also import the transformation logic as a library. `ParseOptions` is exported and documented, which means an application that needs the same resizing semantics can call the same code rather than reimplementing the option grammar.

## Conclusion

imageproxy earns its place by having no configuration file for the common case. The URL is the configuration: `http://localhost/{options}/{remote_url}`, options comma-delimited and order-independent, and the remote URL accepted plain, percent-encoded or base64. Three details decide whether your deployment works. First, access control is one of two things, an allowed-hosts list or HMAC-SHA256 request signing, and signing is also what makes the camo use case viable. Second, cache placement is a flag, with in-memory, disk, Amazon S3, Google Cloud Storage, Azure Storage and Redis all in the same binary. Third, the v0.13.0 pixel flooding fix on 2025-06-29 means older tags let a crafted image request a decode allocation large enough to hurt you, so do not run a pre-2025 build on untrusted input. The v0.12.0 notes say the author now runs it as a Caddy module, which is the shortest path from nothing to a working proxy.

## FAQ

### What is an imageproxy?

It is a caching image proxy server written in Go that fetches a remote image, resizes, crops, rotates or flips it, and caches the result. The entire interface is a URL of the form `http://localhost/{options}/{remote_url}`, where options are a comma-delimited list. Access control is by allowed hosts list or HMAC-SHA256 request signing, and caching can be in memory, on disk, or in S3, GCS, Azure Storage or Redis.

### Is there an open source image resizer available?

imageproxy is one, published under the Apache 2.0 license. It handles jpeg, png, webp for decoding, tiff and gif including animated gifs, resizes and crops through path options rather than a config file, and can be dropped into an existing Caddy setup as a module or run as a single static container binary.

### Should I allow any host in an image proxy?

No, that makes it an open proxy anyone can aim at third-party servers. Use the allowed-hosts list when you serve your own images, and enable HMAC-SHA256 request signing when you proxy arbitrary remote images, which is the configuration the README describes as working like `atmos/camo`. Also run v0.13.0 or later, since it contains a fix for a pixel flooding attack that decodes small images into very large bitmaps.

### How do I run imageproxy in production?

Either as the container, which builds a static binary on Chainguard wolfi-base and runs it with `-addr 0.0.0.0:8080`, or as a Caddy module, which is how the author says he runs it now and removes the separate service entirely. For containers, pull `ghcr.io/willnorris/imageproxy`; images moved to GitHub Package Registry in v0.11.2 and the old `willnorris/imageproxy` name no longer receives updates.

## Sources

- [Issues](https://github.com/willnorris/imageproxy/issues)
- [License: Apache-2.0](https://github.com/willnorris/imageproxy/blob/main/LICENSE)
- [README](https://github.com/willnorris/imageproxy/blob/main/README.md)
- [Releases](https://github.com/willnorris/imageproxy/releases)
- [willnorris/imageproxy on GitHub](https://github.com/willnorris/imageproxy)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/willnorris-imageproxy
