clawbot 8669b5cf04
check / check (push) Waiting to run
Rewrite README.md sentence by sentence (closes #327)
Rewrites README.md from scratch against the code on next, for
#327: what pixa is and how to
use it, deployment, design, the settings reference, then development
and testing. The Configuration list keeps one item per setting, as
TestReadmeNamesEverySetting reads it; the test is unchanged. The
statements where the old text disagreed with the code are listed in
the PR body.

Model: fable-5-1
2026-10-27 03:07:29 +00:00
2026-02-25 18:22:13 +07:00

pixa

pixa is a GPL-3.0-licensed Go web server by @sneak that fetches images from other servers, resizes and converts them, and serves the results.

A web page asks pixa for an image at a size and in a format. pixa fetches the original image, makes the requested image from it, and serves that. It keeps both on disk. Later requests for the same image are served from disk, without a fetch or a conversion.

In this document, the upstream host is the server that holds an original image.

Getting Started

# clone and build
git clone https://git.eeqj.de/sneak/pixa.git
cd pixa
make build

# make a config file from the example and put a real signing key in it
cp configs/config.example.yml config.yml
openssl rand -base64 32   # prints a new signing key
$EDITOR config.yml        # replace the signing_key placeholder with it
./bin/pixad --config config.yml

# or build and run the Docker image
make docker
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest

make build compiles pixa on your machine into bin/pixad. pixad is the server program. Compiling needs the libraries script/bootstrap --cgo installs (see Deployment).

The signing key is pixa's one required secret. It must be 32 characters or longer. The placeholder in the example file is refused at startup.

Once pixa runs, it serves images through URLs of this form:

/v1/image/<host>/<path>/<size>.<format>

For example, this URL serves https://cdn.example.com/photos/cat.jpg at 800x600 as WebP:

/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp

A URL for a host in allowlist_hosts needs nothing more. A URL for any other host needs a signature (see Signed URLs). Someone who knows the signing key can also make an encrypted URL on pixa's own web page (see Encrypted URLs). An encrypted URL works for any host and hides the original image's URL.

The example config allowlists no host. Every image on an allowlisted host is served to anyone, without a signature, at any size and in any format. So allowlist only hosts you control, where nobody else can put images.

A container takes its settings from environment variables (see Configuration). Only PIXA_SIGNING_KEY is required. Without it, the container exits at startup and names the variable. Every other setting has a built-in default. A config file mounted at /etc/pixa/config.yml is read when present. An environment variable wins over the same setting in the file.

Deployment

pixa listens on plain HTTP. It runs behind a reverse proxy: a server in front of it that receives the public requests and passes them on. The reverse proxy terminates TLS, the encryption behind https.

The reverse proxy must:

  • terminate TLS. The login and generator pages work only over HTTPS (see Routes).
  • pass the Host, Origin and Referer headers on unchanged. pixa refuses a form from its pages unless Origin or Referer names the host in Host. It builds encrypted URLs from Host. It checks Referer against referer_blocklist.
  • set X-Forwarded-For to the client's address. trusted_proxies must hold the proxy's address as pixa sees it. The login limit and image_requests_per_minute then count each client by its own address.
  • wait for pixa's answer for at least downstream_timeout (default 60s). That is the longest pixa takes to fetch, convert and send an image.

configs/Caddyfile is an example for Caddy, a web server. Caddy does everything in this list without further settings, and gets its own TLS certificate.

The reverse proxy may also refuse /metrics, as the example does. Only a scraper that reaches pixa directly can then read it. pixa itself asks for metrics.username and metrics.password there.

pixa does the rest itself:

  • it checks signatures and encrypted URLs;
  • it applies the allowlist;
  • it refuses upstream hosts with private or local addresses;
  • it limits login attempts and each client's image requests;
  • it limits the size of upstream responses and the dimensions of images;
  • it sends the security headers, Strict-Transport-Security included, with every response.

The state directory (state_dir, /var/lib/pixa in the container) holds the database and the disk cache:

  • It needs a persistent volume. Without one, every restart starts with an empty cache. Losing the directory also ends every login session (see Encrypted URLs).
  • In the container, the startup script gives the directory to the user pixa runs as, uid 65532, and sets its mode to 750. Outside the container, the user running pixa must be able to write it.
  • cache_max_bytes limits the cached images. The limit does not count the database, or files still being written. Eviction runs in the background, so the cache can pass the limit for a while. Leave room on the volume beyond it.
  • Set cache_max_bytes for a lasting deployment. Its default is worked out at each start from the free space on the volume (see Configuration). Anything else that fills or frees space on the volume moves that default.

A load balancer's health check can request /.well-known/healthcheck.json. It answers 200 whenever pixa is running, in maintenance mode too (see maintenance_mode).

On SIGTERM or SIGINT, pixa stops accepting connections. The requests in progress and the images being processed get 5 seconds to finish. A request not finished by then is cut off. pixa then saves the last-used times it has not yet written (see Storage), and exits. It exits with status 1 when:

  • images were still being processed after those 5 seconds;
  • some of those times could not be saved;
  • another part of pixa failed to stop.

Otherwise it exits with 0. docker stop waits 10 seconds before it kills the container.

Outside Docker, pixa needs these at run time:

  • libvips, the image library pixa converts images with, including its JPEG XL support. pixa does not start without that support. On Alpine it is the vips-jxl package. The Nix and Homebrew packages of libvips include it. So do the Debian and Ubuntu packages, from Debian 12 and Ubuntu 24.04 on.
  • libheif, which libvips needs for AVIF.
  • the system's CA certificates, which HTTPS fetches need.

Compiling pixa needs the development files of libvips and libheif, pkg-config and a C compiler. script/bootstrap --cgo installs them, and libvips and libheif with them. The Dockerfile installs its own packages.

Running under upaas

upaas is the deployment tool pixa is meant to run under. Its app for pixa needs:

  • Port: pixa listens on container port 8080.

  • Volume: container path /var/lib/pixa, where pixa keeps its database and cache.

  • Environment variables:

    • PIXA_SIGNING_KEY (required): the secret for signed URLs, encrypted URLs and login. It is 32 characters or more, for example from openssl rand -base64 32.
    • PIXA_ALLOWLIST_HOSTS: upstream hosts served without a signature, comma-separated; default none. Every image on a listed host is served to anyone, so list only hosts you control, where nobody else can put images.
    • PIXA_CACHE_MAX_BYTES: the disk cache limit in bytes; 0 turns the disk cache off. Set it. The volume is a directory on the host's filesystem. The host and every other app use that filesystem too. The default is 75% of the sum of that filesystem's free space and what the cache already holds. So by default the cache may grow to three quarters of the space the host and the other apps have left. The default is also at least 500 MiB. So when that space is 500 MiB or less, the cache may take all of it.
    • the rest are in the list under Configuration.
  • Memory: pixa can use up to max_concurrent_processing times 4.2 GiB for the images it processes at once. On top of that, the fetched source images waiting for processing can take upstream_connections times upstream_max_response_size. 4.2 GiB is a measured value. It is the largest rise in resident memory measured while pixa saved one 8192x8192 source, the largest it accepts, to each output format at full size. The rise was largest for a 16-bit JPEG XL of random pixels saved as WebP. At the defaults the total is half the CPUs pixa sees times 4.2 GiB, plus 64 times 50 MiB. On a host where pixa sees 48 CPUs, that is 24 times 4.2 GiB plus 3.1 GiB, about 104 GiB. Set PIXA_MAX_CONCURRENT_PROCESSING and PIXA_UPSTREAM_CONNECTIONS so that this fits the memory the container may use.

  • Temporary files: while pixa processes a large source, libvips writes the decoded pixels to a file in /tmp. The file is removed from the directory once it is written, so a listing of /tmp does not show it. It keeps its space until the image is done: on disk, or in memory where /tmp is a tmpfs. For a GIF, WebP, AVIF or JPEG XL source the decoded pixels are held in memory as well, and the 4.2 GiB above includes that memory. For an 8192x8192 source the file was measured at:

    • 192 MiB for an 8-bit RGB JPEG;
    • 512 MiB for a 16-bit RGBA PNG or JPEG XL;
    • 1 GiB for a 32-bit floating-point RGBA JPEG XL.

    So count up to 1 GiB per image processed at once: 24 GiB at the defaults on a host where pixa sees 48 CPUs. This /tmp is in the container's own filesystem, not on the volume at /var/lib/pixa. cache_max_bytes does not count these files, and the 4.2 GiB above does not include them.

  • Health check: the image's HEALTHCHECK requests /.well-known/healthcheck.json. upaas reads the container's health 60 seconds after a deploy and marks the deploy failed unless it is healthy. The probe takes the port from PORT (default 8080). It does not see a port changed only in a mounted config file, so change the port with PORT.

Rationale

A web application that shows images needs each one at several sizes and in several formats. pixa makes them on request from one original, and keeps what it made. The application stores one image per picture and hands out URLs. pixa is one binary. At run time it needs only the image libraries and the CA certificates named under Deployment. Signed URLs with an expiry keep strangers from using it to resize anything they like. Allowlisted hosts give open access where that is wanted.

Design

Concepts

These terms are the names the code uses:

  • Source: the image at one upstream URL, named by its host, path and query.
  • Source key: the SHA-256 hash of a source's host, path and query. SHA-256 is a hash function: it turns any text into a fixed-length fingerprint. The source key names the source's file and its row in the database.
  • Transform: the output size, format, quality and fit that an image request asks for. With the format auto, pixa chooses the output format from the Accept header.
  • Image request: a source and a transform. Each image route reads one from its URL.
  • Variant: the image pixa serves for an image request, made from its source with its transform.
  • Variant key: the SHA-256 hash of a variant's source, size, format, quality and fit. It names the variant's file and its row in the database, and gives its ETag.
  • Signed URL: a /v1/image/ URL whose sig parameter signs its image request and its expiry exp with signing_key.
  • Encrypted URL: a /v1/e/ URL whose token holds its image request and any expiry, encrypted with a key derived from signing_key.
  • Login session: an encrypted cookie that keeps a browser logged in to the generator page. It lasts 30 days, or until a logout (see Encrypted URLs).
  • Negative cache: the source URLs pixa does not fetch again for 5 minutes, after a fetch failed in one of these ways:
    • the upstream answered with an error status;
    • the body's first bytes were not those of the format its Content-Type names.
  • Image cache: the part of pixa that keeps the sources, the variants and the negative cache (see Storage).
  • Disk cache limit (cache_max_bytes): the size in bytes that the cached sources and variants may reach together.
  • Image proxy: the part of pixa that serves a variant from the image cache when it can, and otherwise makes it from the source.

Routes

pixa answers these routes. Any other path answers 404. A listed path answers 405 to any method the list does not give for it.

  • GET / — the login page, or the generator page with a login session (see Encrypted URLs). Needs nothing. Answers 200.
  • POST / — log in with the signing key typed into the login page. Needs the login page's form (below). For the right key it answers 303 to / and sets the login session cookie. For a wrong key it answers 200 with the login page and an error. Answers 429 over the login limit (see Limits).
  • POST /generate — make an encrypted URL from the generator page's form. Needs a login session and the generator page's form. Without a login session it answers 303 to /. Answers 200 with the page showing the URL. Answers 400 with the page naming a field that is not valid. Answers 500 when the URL cannot be made.
  • POST /logout — log out with the generator page's Logout button. That ends every login session, in every browser (see Encrypted URLs), and removes the cookie. Needs a login session and the generator page's form. Without a login session it only removes the cookie. Answers 303 to /. Answers 500 when the logout cannot be saved in the database.
  • GET or HEAD /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size> with no format — an image, fetched, resized and converted (see Image URLs). Needs a signature, unless the host is allowlisted (see Source Hosts). Answers:
    • 200 with the image.
    • 304 when If-None-Match matches the image's ETag.
    • 400 for a URL or parameter that is not valid.
    • 400 when the format is auto and the Accept header is not valid.
    • 406 when the format is auto and Accept allows none of the formats it chooses from.
    • 401 for a missing or wrong signature.
    • 401 for a missing exp.
    • 401 for an exp in the past.
    • 403 when the request's Referer names a host in referer_blocklist. This is checked before the signature, the cache and the upstream fetch.
    • 403 when the upstream host, or a host it redirects to, is localhost or ends in .localhost or .local.
    • 403 when the upstream host, or a host it redirects to, has an address in a blocked network (see blocked_networks).
    • 429 over image_requests_per_minute (see Limits).
    • 499, with no body, when the client goes away before its image is ready, unless its request is the one making the image (see Limits).
    • 415 when the upstream answers with a Content-Type that is not one of the source formats (see Image URLs).
    • 415 when the upstream sends a body whose first bytes are not those of the format its Content-Type names.
    • 415 when the upstream sends an image whose first bytes libvips reads as another format.
    • 502 when the upstream answers with an error status.
    • 502 for 5 minutes after that for the same source URL, when pixa has neither the image nor its source in its cache.
    • 502 likewise for 5 minutes after a body whose first bytes are not those of its Content-Type.
    • 503 when pixa is busy (see Limits).
    • 503 in maintenance mode.
    • 504 when the upstream has not sent its response headers within upstream_fetch_timeout.
    • 500 when that time runs out while the image itself is still arriving.
    • 500 for any other failure.
  • GET or HEAD /v1/e/<token>/<name> — an image through an encrypted URL (see Encrypted URLs). Needs nothing but the URL. Answers:
    • 200 with the image.
    • 304 when If-None-Match matches the image's ETag.
    • 400 for a token that does not decrypt.
    • 400 for a token that asks for a size or fit that is not valid.
    • 410 once the token has expired.
    • 400 when the token's format is auto and the Accept header is not valid.
    • These answers, for the same causes as on /v1/image/:
      • 406
      • 403
      • 415
      • 429
      • 499, with no body
      • 502
      • 503
      • 504
      • 500
  • GET /robots.txt — asks every crawler to stay away (Disallow: /). Needs nothing. Answers 200.
  • GET /.well-known/healthcheck.json — JSON with status (ok), now, uptime_seconds, uptime_human, version, appname and maintenance_mode. Needs nothing. Answers 200, always.
  • GET /static/<file> — the stylesheet and script the login and generator pages load. Needs nothing. Answers 200, or 404 for a file that does not exist. It answers any other method as it answers GET.
  • GET /metrics — Prometheus metrics (see Architecture). Needs HTTP basic authentication with metrics.username and metrics.password. Answers 200. Answers 401 without them. Answers 404 when they are not set, as the route then does not exist.

A browser's CORS preflight request to any path under /v1/ answers 200, in maintenance mode too. CORS is the browser rule that lets a page on one site read responses from another. The preflight is the OPTIONS request the browser sends first.

Every response carries an X-Request-Id header with the request's ID. A client can quote it when reporting a problem. A reverse proxy may send an X-Request-Id header with the request. pixa keeps that ID when it is 1 to 64 letters, digits, -, _ or .. Otherwise pixa makes a random ID, which tells nothing about the machine or the other requests. pixa's log line for the request carries the same ID as request_id. So do the lines it logs when it fetches, converts and serves an image. The fetch sends it to the upstream host as X-Request-Id.

The POST routes accept only a form that pixa's own page served. The page puts a token in the form and sets a cookie to match. A request without both is refused with 403. So another site cannot submit the form from a visitor's browser. A form is also refused with 403 when the page's host is not the Host header pixa receives. So a reverse proxy in front of pixa must pass that header on unchanged. A form body over 1 MiB is refused with 413.

The login and generator pages are meant to be opened over HTTPS. While debug is off, a form sent from a page opened over plain HTTP is refused with 403. While debug is on, a form sent from a page opened over HTTPS is refused instead. Plain HTTP is for development on the browser's own machine. The login session cookie is always marked Secure. Over plain HTTP a browser keeps such a cookie only for its own machine (localhost), if at all.

The image routes answer the errors listed for them with a JSON body holding error, status and timestamp.

Image URLs

An image URL has one of these forms. The second has no format:

/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
/v1/image/<host>/<path>/<size>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
  • <host>: the upstream host, with a port when the source URL has one (see Source Hosts).
  • <path>: the source URL's path, and its query after %3F (below).
  • <size>: orig or <width>x<height>, for example 800x600. A side of 0 is worked out from the other side and the source's proportions (below). orig is 0x0: the source's own size.
  • <format>: orig (or original), jpeg (or jpg), png, webp, avif, jxl (JPEG XL), gif, or auto (below). orig is the source's own format. A URL with no format is served as JPEG XL, the default, as with jxl.
  • sig and exp: the signature and its expiry, needed unless the host is allowlisted (see Signed URLs).
  • q: the output quality, a whole number from 1 to 100. It is 85 when the URL has no q.
  • fit: how the image is fitted to <size>: cover, contain, fill, inside or outside (below). It is cover when the URL has no fit.

q and fit are part of what is cached, so each value of either is a separate cached image. A request is refused with 400 when its query string cannot be decoded, or when it gives any parameter more than once. A request with a q, fit or exp that is empty or not valid is refused with 400 too.

pixa fetches sources over HTTPS only, and only from hosts with valid certificates. With allow_http set, it fetches every source over plain HTTP instead. That is for testing only.

The source path and query. A source URL's query follows its path after %3F, the encoded ?:

/v1/image/<host>/<path>%3F<query>/<size>.<format>

pixa decodes the percent-escapes in the image URL's path once. It then reads the host up to the first /, and the size and format after the last /. Between them it reads the source path up to the first ?, and the source query after it. So these must be percent-encoded, as %3F and %25:

  • the ? before the query;
  • any ? in the query;
  • every % in the source path or query.

Any other character may be written as itself or percent-encoded. /, ;, ,, = and & work either way. Neither the source path nor the source query may hold a #. An image URL that writes one as %23 is refused with 400. For example, the image https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg at 800x600 as WebP is:

/v1/image/cdn.example.com/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp

Signed URLs shows this URL with its signature.

pixa keeps the source path decoded and fetches it percent-encoded again. Every character other than these is sent as a percent-escape: letters, digits, -, ., _, ~, /, $, &, +, ,, :, ;, = and @. So:

  • my%20cat.jpg in the image URL is the source path /my cat.jpg, fetched as /my%20cat.jpg;
  • my%2520cat.jpg is the source path /my%20cat.jpg, fetched as /my%2520cat.jpg;
  • 100%25.png is fetched as /100%25.png.

The source query is sent as the source URL writes it, its percent-escapes included. Two kinds of character are percent-encoded first: those a browser percent-encodes in a query, and a % that does not begin a percent-escape. The first kind is a space, ", ', <, >, control characters and characters outside ASCII. pixa signs and caches the query in that form. So img%3Fname=my%20cat and img%3Fname=my%2520cat in the image URL are the same source query, name=my%20cat.

Sizes. The largest width or height is 8192, for the source and for the output. A size with one side 0 has that side worked out from the other side and the source's proportions. The side is rounded down, but is at least 1. When it would come out above 8192, the request is refused with 400. The fit modes:

  • cover: scale the image to cover the size, keeping its proportions, and cut out the centre. The output is exactly the size.
  • contain: scale the image to fit inside the size, keeping its proportions. The output can be smaller than the size on one side.
  • fill: stretch the image to the size, without keeping its proportions.
  • inside: as contain, but never enlarge the image.
  • outside: scale the image to cover the size, keeping its proportions, without cutting. The output can be larger than the size on one side. An 8192x1 source at 100x0 is 100x1 by its proportions, but comes out at 8192x1. A side that would come out above 8192 is refused with 400.

An AVIF output of more than 16,777,216 pixels is refused with 400 before anything is decoded. That is the number of pixels in 4096x4096 or 8192x2048. A larger AVIF encode could take more than 40 seconds (see Limits). The limit includes orig of an AVIF source, and auto when it chooses AVIF. So a client whose Accept names image/avif but not image/jxl is refused such a size.

Source formats. The source may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's Content-Type must name its format, and the image's first bytes must match it. An upstream answering with any other Content-Type, SVG included, is refused with 415. So is a body whose first bytes are not those of the format its Content-Type names: a JPEG sent as image/png, text sent as an image, or an empty body. pixa then does not fetch that source URL again for 5 minutes, as after an upstream error status. Meanwhile it answers 502 for an image that would need that fetch. An image whose first bytes libvips reads as another format is refused with 415 too. An SVG whose first bytes pass the check for AVIF is one example.

The format auto. With auto, pixa chooses the format for each request from its Accept header, in this order:

  1. JPEG XL, when the header names image/jxl;
  2. AVIF, when it names image/avif;
  3. WebP, when it names image/webp;
  4. JPEG, when the most specific of image/jpeg, image/* and */* that the header names has a q above 0, or when there is no Accept header or it is empty.

An entry with q=0 refuses its format. Other q values do not change the order. JPEG XL, AVIF and WebP must be named, as clients that cannot show them also send image/* and */*. pixa never sends a format the client refused. When the header allows none of the four, the answer is 406. A header that does not parse, or has a q that is not a number from 0 to 1, is refused with 400. The signature, or the token of an encrypted URL, covers auto itself, so one URL serves every client. Each format chosen is cached as a separate image. Every answer that depends on Accept carries Vary: Accept: the image, a 304, and the 400 and 406 above. So a shared cache keeps the formats apart too.

Response headers. An image is served with Cache-Control: public, max-age=<seconds>, immutable. When the URL has an expiry (an exp, or the expiry of an encrypted URL), max-age is the whole seconds left until then, at most one year. So no browser or proxy cache keeps the image after pixa would refuse the URL. A URL with no expiry gets one year. immutable only stops a client revalidating while its copy is fresh. The response also carries X-Pixa-Cache: HIT for an image served from the cache, and MISS for one made for this request. The ETag is the first 16 characters of the variant key, in quotes.

Signed URLs

A signed URL is a /v1/image/ URL with two query parameters: exp, the Unix time (seconds since 1 January 1970) when it expires, and sig, its signature. The signature is an HMAC-SHA256: a hash of the request's details, keyed with signing_key, that only someone who knows the key can make. Signatures match exactly: every part of the signed data must be exactly what was signed. There is no suffix, wildcard or partial matching.

The signed data is one line of nine parts, joined with ::

HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
  • host: the source host as the URL writes it up to the first /, in lower case. An IPv6 address keeps its brackets, and a port is kept after its :. Examples: cdn.example.com, cdn.example.com:8443, [2001:db8::1]:8443.
  • path: the source path with its percent-escapes decoded, as under Image URLs, then with each % written as %25 and each : as %3A. So /photos/my%20cat.jpg in the URL is signed as /photos/my cat.jpg, and /photos/a:b.jpg as /photos/a%3Ab.jpg.
  • query: the source query as under Image URLs, with % and : written as in path. It is empty when there is no query. Neither part then holds a :, so path /a:b with no query and path /a with query b: are signed differently.
  • width and height: the requested size in pixels. A side of 0 is signed as 0, and orig as 0 and 0.
  • format: the output format as listed under Image URLs, with original signed as orig, jpg as jpeg, and no format as jxl. So a URL with no format has the signature of the same URL ending in .jxl. auto is signed as auto, not as the format chosen for the request.
  • expiration: the URL's exp, the Unix time in seconds when the signature expires. A request whose exp is not a whole number is refused with 400. An empty exp= is one example.
  • quality: the URL's q, or 85 when the URL has no q.
  • fit: the URL's fit, or cover when the URL has no fit.

The URL's sig is the HMAC-SHA256 result in base64url, the URL-safe alphabet of RFC 4648, with the trailing = padding kept: 44 characters in all. pixa compares it exactly. A signature encoded without padding is refused with 401. Node's base64url and Go's base64.RawURLEncoding encode without padding.

Example. With the signing key example-signing-key-for-documentation, resize https://cdn.example.com/photos/cat.jpg to 800x600 WebP, expiring at 1704067200, with the default quality and fit:

  1. Build the signed data: cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover
  2. Compute its HMAC-SHA256 with the signing key.
  3. Encode the result as base64url, keeping the = padding: -ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=
  4. The URL: /v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200

For the same image at quality 40 with fit contain, the signed data ends in :40:contain. The signature is 5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=, and the URL is /v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain.

For a source URL with a query, https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg, at 800x600 WebP with the same key, expiry, quality and fit:

  1. The signed data, with the query's : written as %3A and its % as %25: cdn.example.com:/img:url=https%3A//origin.example.com/my%2520cat.jpg:800:600:webp:1704067200:85:cover
  2. The signature: 37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA=
  3. The URL, with the ? before the query written as %3F and the query's % as %25: /v1/image/cdn.example.com/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp?sig=37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA=&exp=1704067200

Encrypted URLs

An encrypted URL is an image URL made on pixa's own web page by someone who knows the signing key. It works for any upstream host, allowlisted or not, without a signature. Whoever gets it can neither read the source URL from it nor change what it asks for.

  1. Open / in a browser over HTTPS and log in with the signing key. The login session lasts 30 days, or until someone logs out with the generator page's Logout button.
  2. On the generator page, fill in the form. Its fields are listed below. Then submit it (POST /generate).
  3. The page shows the URL, https://<host>/v1/e/<token>/img.<format>, and when it expires. <host> is the host the page was opened on. While debug is on, the URL starts with http instead. The name after the token is ignored. It only gives the URL a file extension: jpg for orig and auto, and jxl for a form with no format.

The form's fields:

  • The source image's URL. Its scheme is not kept (below).
  • The width and height. Both empty or 0 keep the source's size. When only one of them is empty or 0, that side is worked out from the source's proportions when the image is made (see Image URLs).
  • The format: orig, auto, jpeg, png, webp, avif, jxl or gif. jxl is selected unless another is chosen. A form sent with an empty format, or none, also makes a JPEG XL URL.
  • The quality, a whole number from 1 to 100. The page offers five values: 25, 50, 70, 85 and 100. 85 is selected unless another is chosen.
  • The fit, one of the five fit modes (see Image URLs). cover is selected unless another is chosen.
  • How long the URL lasts, in seconds, or 0 for never. The page offers seven choices: never, 1 minute, 1 hour, 1 day, 1 week, 30 days and 1 year. Never is selected unless another is chosen.

The page refuses a form with 400 when:

  • the source URL does not parse, or has no host;
  • the source URL's path holds a .. segment, as /v1/image/ refuses one;
  • a width, height, quality or expiry is not a number, or is out of range;
  • the fit is not one of the five fit modes;
  • pixa would fetch the source URL in another form (below).

The page refuses a source URL that pixa would fetch in another form, and shows the URL pixa would fetch. A URL with a user name and password is one example: pixa does not send them. A URL ending in ? with no query is another. A path holding %2F is another: pixa would fetch it as /. So is a path holding (: pixa would fetch it as %28 (see Image URLs).

The image is made only when the URL is requested. A side that would come out above 8192, or an AVIF output of more than 16,777,216 pixels, is refused with 400 then (see Image URLs).

Every login is made with the one signing key. So logging out ends every login session, in every browser, and a copied session cookie too. pixa keeps a random value in its database. It makes the value when the database has none, and replaces it at each logout. A login session carries the value current at its login. Once the value has changed, the session is refused, after a restart too. The check compares no time, so a login after a logout works whatever the host's clock says. The 30 days are still measured by the host's clock.

The token holds the source's host, path and query, and the size, format, quality, fit and expiry. It is encrypted with a key derived from signing_key. The host is kept in lower case and an empty path as /, as for /v1/image/. So https://CDN.Example.com and https://cdn.example.com/ are one source, fetched and cached once. The source URL's scheme is not kept. The image is fetched like any other (see Image URLs), and the blocked networks still apply.

The expiry is fixed in the token when the URL is made. It cannot be changed or revoked afterwards. Until then the image is served with a max-age that ends at the expiry (see Image URLs). After it, the URL answers 410 URL has expired. A URL made to last forever holds no expiry. It stops working only when signing_key changes. Changing the key makes every encrypted URL already handed out answer 400, and ends every login session.

Source Hosts

Hosts in allowlist_hosts are served without a signature. Any other host needs one. An entry is written in one of two ways:

  • cdn.example.com: an exact match, only that host;
  • .example.com: a suffix match, which matches cdn.example.com, images.example.com and example.com itself.

An IP address is matched exactly. Write an IPv6 address without brackets. An entry that is neither a host name nor an IP address aborts startup. A host name here is letters, digits, hyphens, underscores and dots, with at most one leading dot. So an entry with a port, or a *. wildcard, aborts startup.

pixa lower-cases the host of a /v1/image/ URL before anything else reads it. So CDN.Example.com is matched, fetched, cached and counted against upstream_connections_per_host as cdn.example.com. A URL whose host has a port needs a signature even when the host is allowlisted. So does one whose host ends in : with no port. cdn.example.com:8443 is a host with a port. The host must be one of:

  • a host name or an IPv4 address: letters, digits, hyphens, underscores and dots;
  • an IPv6 address in brackets.

Either may be followed by : and a port. Any other host is refused with 400. One holding @ is an example. So is one with a ?, #, %, \ or space written percent-encoded.

Image Metadata

pixa decodes and re-encodes every image it serves, and removes all metadata from the output. This cannot be turned off. The metadata removed is:

  • EXIF, which can hold the GPS position, the camera make, model and serial number, the capture time and an embedded thumbnail;
  • XMP and IPTC, two other metadata formats;
  • the ICC colour profile.

The rest of the rules:

  • The orig format means the source's own format, not the source's bytes. An orig image is re-encoded and stripped like any other.
  • An image with an EXIF orientation is turned upright first, so it displays the same without the tag. A requested size applies to the upright image.
  • An image with an ICC profile is first converted to sRGB, the standard colour space, since clients show an image with no profile as sRGB. Colours outside sRGB are clipped: the most saturated ones in a Display P3 photo, for example.
  • JPEG XL output is the one exception, with libvips 8.16 and later. libvips then writes an EXIF block of its own, which pixa cannot stop. The block holds the image's size and fixed values: an orientation of 1 and a resolution of 72 dpi. Nothing in it comes from the source. The Docker image has libvips 8.16, so its JPEG XL output carries this block.

Limits

Shared work. Several requests for one variant can miss the cache at the same time. They then share one upstream fetch, or one read of the cached source, and one conversion. The first request does the work. The others wait for its image or its error, and hold no upstream connection or processing slot of their own. A waiting request stops waiting when its own client goes away. It is then answered 499. The work goes on for the others even when the first request's client goes away. The shared fetch sends the first request's ID upstream. The lines logged for the fetch and the conversion carry that ID.

Timeouts. downstream_timeout (default 60s) bounds the whole answer to one request. The upstream fetch counts toward it. So do the waits for a connection slot and for a processing slot. The wait for a shared upstream_connections slot ends after 10 seconds, and so does the wait for a processing slot. The wait for an upstream_connections_per_host slot ends only with downstream_timeout. Keep downstream_timeout longer than upstream_fetch_timeout plus 20 seconds.

A conversion whose request's downstream_timeout has ended stops waiting for a processing slot. If its downstream_timeout ends after it has a slot but before it begins to encode, it gives the slot back. For a shared conversion, the first request's deadline counts. When a shared conversion stops this way, each request waiting for it is answered 503 server busy, try again later, even if its own downstream_timeout has not ended. govips, the library pixa drives libvips through, gives no way to stop an encode once it has begun. So an encode keeps its slot until it finishes, which can be after downstream_timeout.

Encode times. The limits on image size bound how long a conversion can take. They are 8192 on each side of the source and of the output, and 16,777,216 pixels for an AVIF output. Decoding an 8192x8192 source takes up to about 11 seconds. The WebP and JPEG XL encoders run below libvips' default effort. AVIF runs at the lowest effort govips can set. With one libvips thread, as pixa runs it, encoding 8192x8192 random pixels takes up to about:

  • 1 second as JPEG;
  • 15 seconds as PNG;
  • 16 seconds as GIF;
  • 23 seconds as WebP with an alpha channel, and 16 without;
  • 27 seconds as JPEG XL with an alpha channel, and 13 without.

An AVIF output of 4096x4096, 8192x2048 or 2048x8192 random pixels takes up to about 34 seconds with an alpha channel, and 22 without. At 8192x8192 and quality 85 it would take about 96 seconds with an alpha channel, and 52 without. These times hold at any quality and at 8 or 16 bits per sample. No encode within the limits takes more than about 40 seconds of the default downstream_timeout of 60.

Processing and connections. max_concurrent_processing images are decoded and encoded at once. A request that finds every slot in use waits up to 10 seconds for one. If none frees up, it is answered 503 server busy, try again later. upstream_connections bounds the fetches in progress to all upstream hosts together, and upstream_connections_per_host those to one host. A fetch holds its connection until its image has been processed. A fetch that finds every shared connection in use waits up to 10 seconds the same way, then answers 503. A fetch that finds every connection to its host in use waits for one until downstream_timeout ends.

Client limits. The login form (POST /) is limited to 5 attempts per minute per client address. An attempt over the limit is refused with 429 and a Retry-After header. One client may send image_requests_per_minute requests per minute (default 600) to /v1/image/ and /v1/e/ together, GET and HEAD, cache hits included. A request over the limit is refused with 429 and a Retry-After header. A browser's CORS preflight is not counted. 0 turns the limit off. Both limits count an IPv6 client by its /64.

The client address. Behind a reverse proxy, pixa takes the client address from X-Forwarded-For only when the request arrives from an address in trusted_proxies. Otherwise all users behind the proxy count as one client. The address the request arrives from is not always the proxy's own. A proxy on the Docker host may connect to pixa over 127.0.0.1. pixa then sees the gateway of the container's Docker network, such as 172.17.0.1 on the default bridge. A proxy that connects through another of the host's addresses is seen with that address. To be sure, find the address as described under trusted_proxies (see Configuration). The default trusted_proxies is the RFC 1918 ranges, the private address ranges. With that default, a client with a private address is itself trusted. It can then choose the address it is counted by, through its own X-Forwarded-For. Setting trusted_proxies to only the proxy's address closes this. Behind a content delivery network (CDN) or proxy whose addresses are not in trusted_proxies, all requests arriving from one address count as one client. Such a deployment needs a higher image_requests_per_minute, or 0.

Request limits. pixa reads a request's headers within 10 seconds and the whole request within 30 seconds. Headers are limited to 8 KiB. An idle connection is closed after 120 seconds. A form body over 1 MiB is refused with 413.

Storage

  • Sources: <state_dir>/cache/sources/<ab>/<cd>/<source key>, one file for each source URL
  • Database: <state_dir>/state.sqlite3
  • Variants: <state_dir>/cache/variants/<ab>/<cd>/<variant key>

<ab> and <cd> are the first and second pairs of characters of the file's name. The database is one SQLite file. SQLite is the database library pixa embeds; nothing else needs to run. By default the database is in WAL mode, SQLite's write-ahead log (see db_url). SQLite then keeps state.sqlite3-wal and state.sqlite3-shm beside the file while pixa runs.

The image cache keeps these tables in the database:

  • source_content: a row for each source, by source key, with its size and when it was last used
  • variant_content: a row for each variant, by variant key (column cache_key), with its size and when it was last used
  • cache_usage: the total size of the cached sources and variants, which eviction reads
  • negative_cache: a row for each source URL in the negative cache, with when it expires

The logout table holds the random value that a logout replaces (see Encrypted URLs).

pixa reads a cached source's format from its file's first bytes. When they match none of the source formats, pixa fetches the source again. Two source URLs that serve the same bytes are stored twice, once under each URL. A source is stored by writing its file and then its row. When the row cannot be written, the file is removed again.

Each source and variant file is written under a temporary name and synced to disk before it is renamed into place. So a crash never leaves part of a file under its own name.

When pixa starts, and then at the spacing below, it checks the cached files against the database. It removes each source or variant whose file is empty, or not the size its row records. It logs a warning naming the file. A later request for that image fetches the source or makes the variant again. The check also drops the rows of variants whose files are missing. When pixa cannot read a variant file's size for another reason, it keeps the variant's row and logs a warning naming the variant key and the error. During a check, pixa skips a directory it cannot list and a file whose size it cannot read. It logs a warning naming the path and the error, and goes on with the rest of the check. A file renamed or removed after pixa listed its directory gets no warning.

A check reads the database once for every cached file, so the checks are spaced out. After a check ends, pixa waits at least ten times as long as that check took. Eviction runs every five minutes (below). The next check starts at the first of those runs after that wait. After a check that took one minute, the next one starts 10 to 15 minutes after that check ended.

A cache hit opens the variant's file and nothing else. It makes no database call. The content type comes from the file's first bytes, which are those of the format pixa wrote it in. A file whose first bytes are not those of a format pixa writes is not served, and the image is converted again. A hit hands the time it was served to a background writer and does not wait for the writer. Eviction orders by that time. The hit is counted in memory, for the pixa_cache_hits_total metric. A miss that uses a cached source also hands that writer the time it used the source. Once a second the writer writes the waiting times in one transaction. Up to 10,000 access times of variants, and as many of sources, can wait to be written. A time handed over beyond that is dropped. Its image then only looks less recently used to eviction. script/bench measures how many hits pixa serves per second (see Load Test).

Eviction removes the least recently used sources and variants until the cache is within cache_max_bytes. It runs after each store, and every five minutes. It also removes the expired rows of the negative cache every five minutes, at most 1000 per statement, so a request waits behind 1000 deletions at most.

Configuration

Every setting can be given as an environment variable, in a config file (--config), or both. The file is YAML, a plain-text format. A variable present in the environment wins over the file, even when it is empty. The file wins over the built-in default. pixa reads a variable's value as a quoted string in the file. So PIXA_DEBUG=1 turns debug on, but debug: 1 in the file aborts startup. A setting that holds a list takes comma-separated entries, with the spaces around each trimmed. An empty variable is an empty list.

The file's env: section is the one exception to that order. pixa sets each variable named there while it loads the file. So that variable wins over the environment pixa started with, and over the file's key for the same setting.

pixa refuses to start when a value does not parse or is invalid. The error names the setting. It also refuses to start when a key in the file is set to null. A key written as key: with nothing after it is null. Omit the key to use the default. It refuses to start when the file has a key that is not in the list below, and the error names the key. It refuses to start when a variable starts with PIXA_ but is not in the list below, such as a misspelled one or PIXA_PORT. The error names the variable. The variables set by the file's env: section are checked the same way.

pixa also accepts PIXA_CONFIG_PATH, which names the config file, like --config.

pixa reads at most one config file. It reads the file given with --config (or -c), or else the one PIXA_CONFIG_PATH names. pixa refuses to start when that file does not exist, cannot be read or does not parse. Without --config or PIXA_CONFIG_PATH, pixa looks in /etc/pixa/, then in ~/.config/pixa/, then in the working directory. In each directory it looks for config.yml, then config.yaml. It reads the first file it finds. It skips a missing file without a message. pixa refuses to start when a file it finds cannot be read or does not parse, and the error names the file. It also refuses to start when it may not enter one of these directories, whether or not the file is there, since it cannot tell. With no file, pixa uses the environment and the defaults.

Durations are written as Go durations, such as 30s or 2m, and must be positive. A bare number has no unit and aborts startup. Sizes are a whole number of bytes. A CIDR range is an address range written as an address and a prefix length, such as 10.0.0.0/8. An address matches a range that contains it.

blocked_networks and trusted_proxies hold CIDR ranges. For both, an IPv6 address in the IPv4-mapped form (::ffff:a.b.c.d) is matched only as the IPv4 address it holds. So an IPv6 range such as ::/0 matches neither it nor a plain IPv4 address. A range written in that form is read as the IPv4 range it covers: ::ffff:10.0.0.0/104 is 10.0.0.0/8. Such a range shorter than /96 covers more than IPv4 addresses and aborts startup.

Each item below is one setting, named by its config key:

  • signing_key: variable PIXA_SIGNING_KEY, required. The secret for signed URLs, encrypted URLs and login. It must be 32 characters or longer. The placeholder in configs/config.example.yml is refused. pixa uses the key as it is for two things: as the HMAC key of signed URLs (see Signed URLs), and as the password of the login page. From it, pixa derives four more keys at startup, with HKDF-SHA256, a key derivation function. One signs the session cookie and one encrypts it. One encrypts the token of an encrypted URL. One signs the token cookie of the login and generator pages' forms.
  • port: variable PORT, default 8080. The port to listen on, from 1 to 65535.
  • state_dir: variable PIXA_STATE_DIR, default /var/lib/pixa. The directory for the database and the disk cache. pixa creates it at startup when it is missing. It refuses to start when it cannot create or write the directory.
  • db_url: variable PIXA_DB_URL, default file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL). The SQLite database to open. The default keeps the database in WAL mode, SQLite's write-ahead log, which lets reads go on during a write. pixa adds _pragma=busy_timeout(5000) to any db_url. A write that finds another program writing to the file then waits up to five seconds for it instead of failing. pixa opens one connection, so its own reads and writes run one at a time. WAL mode comes only from the URL: keep _pragma=journal_mode(WAL) in one you set. An empty db_url aborts startup: omit the key to use the default. In the default, state_dir is percent-encoded: a %, ? or # in the name is written as %25, %3F or %23. For a state_dir of /, or one starting with //, the default starts with file:////, so SQLite does not read the first name in the path as a host.
  • cache_max_bytes: variable PIXA_CACHE_MAX_BYTES, default below. The disk cache limit in bytes. When the key is omitted, pixa works the limit out at startup. It takes the free space on the filesystem holding <state_dir>/cache/, adds the bytes the cache already holds, and takes 75% of the sum. The default is at least 500 MiB. A value you set is used exactly as given. 0 turns the disk cache off: every request then fetches and converts its image, and only the negative cache is used. A negative value aborts startup.
  • allowlist_hosts: variable PIXA_ALLOWLIST_HOSTS, default none. The upstream hosts whose images pixa serves without a signature, written as under Source Hosts. Every image on a listed host is served to anyone, so list only hosts you control. An entry that is neither a host name nor an IP address aborts startup.
  • referer_blocklist: variable PIXA_REFERER_BLOCKLIST, default none. Hosts whose pages may not show pixa's images, to stop other sites embedding them. Entries are written and matched as for allowlist_hosts. A request whose Referer header names a listed host is refused with 403. That applies to /v1/image/ and /v1/e/. It happens before the signature or token is checked, and before the cache or the upstream host is used. The request is refused even when the image is cached. A request with no Referer is served, as many clients send none. So is one whose Referer does not parse as a URL with a host. So a site whose pages send no Referer, for example with Referrer-Policy: no-referrer, is not stopped. The login and generator pages are not covered.
  • blocked_networks: variable PIXA_BLOCKED_NETWORKS, default none. CIDR ranges pixa never fetches from, on top of the built-in ones. An invalid CIDR aborts startup. The built-in ranges always apply. They are:
    • 0.0.0.0/8, this network, 0.0.0.0 included;
    • 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, the private ranges;
    • 100.64.0.0/10, carrier-grade NAT;
    • 127.0.0.0/8, loopback;
    • 169.254.0.0/16, link-local;
    • 192.0.0.0/24, IETF protocol assignments;
    • 198.18.0.0/15, benchmarking;
    • 224.0.0.0/4, multicast;
    • ::, the unspecified address;
    • ::1, loopback;
    • 64:ff9b::/96 and 64:ff9b:1::/48, NAT64;
    • fc00::/7, the unique local (private) range;
    • fe80::/10, link-local;
    • ff00::/8, multicast.
  • trusted_proxies: variable PIXA_TRUSTED_PROXIES, default the RFC 1918 private ranges: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. CIDR ranges of the reverse proxies in front of pixa. X-Forwarded-For is believed only when the request reaches pixa from an address in one of these ranges. The client address is then the rightmost entry of the header that is not itself a trusted proxy. Otherwise the header is ignored and the address the request came from is used. So a client connecting from outside these ranges cannot choose its address. An explicitly empty list ([], or an empty variable) trusts no one. A list you set replaces the default. An invalid CIDR aborts startup. Set this to the address pixa sees for requests that come through your proxy, such as 172.17.0.1/32. Do so when the default does not cover it, or to trust nothing else (see Limits). To find that address, set this to [], send a request through the proxy, and read remoteIP in pixa's log line for it.
  • allow_http: variable PIXA_ALLOW_HTTP, default false. While true, pixa fetches every source over plain HTTP, and follows redirects to plain HTTP. It is for testing only.
  • upstream_connections_per_host: variable PIXA_UPSTREAM_CONNECTIONS_PER_HOST, default 20. The most connections to one upstream host at once. It must be at least 1. A fetch that finds every connection to its host in use waits for one until downstream_timeout ends (see Limits).
  • upstream_connections: variable PIXA_UPSTREAM_CONNECTIONS, default 64. The most connections to all upstream hosts together at once, on top of the limit per host. It must be at least 1. A fetch holds its connection until its image has been processed (see Limits).
  • max_concurrent_processing: variable PIXA_MAX_CONCURRENT_PROCESSING, default half the CPUs Go uses (GOMAXPROCS), rounded down, and at least 1. The most images decoded and encoded at once. GOMAXPROCS follows a container's CPU limit. A value that is not a whole number of at least 1 aborts startup. Each image can take a lot of memory (see Running under upaas).
  • image_requests_per_minute: variable PIXA_IMAGE_REQUESTS_PER_MINUTE, default 600. How many requests one client may send per minute to /v1/image/ and /v1/e/ together (see Limits). 0 turns the limit off. A negative or non-integer value aborts startup.
  • upstream_fetch_timeout: variable PIXA_UPSTREAM_FETCH_TIMEOUT, default 30s. The time allowed for one fetch from an upstream host. A fetch whose response headers have not arrived by then answers 504.
  • upstream_max_response_size: variable PIXA_UPSTREAM_MAX_RESPONSE_SIZE, default 52428800 (50 MiB). The largest upstream response accepted, in bytes, from 1 to 1073741824 (1 GiB). It also limits the image data pixa decodes. A cached source larger than it is fetched again.
  • downstream_timeout: variable PIXA_DOWNSTREAM_TIMEOUT, default 60s. The time allowed for answering one client request. Keep it longer than upstream_fetch_timeout plus 20 seconds (see Limits).
  • access_control_allow_origin: variable PIXA_ACCESS_CONTROL_ALLOW_ORIGIN, default *. The origin a browser lets read the responses of /v1/image/ and /v1/e/, sent as the CORS Access-Control-Allow-Origin header. No other route sends it. * is any site. Otherwise it is one http or https origin such as https://example.com. Its host is a lowercase host name (letters, digits, hyphens and dots, with a letter in its last part) or an IP address (IPv6 in brackets, in its shortest form). It may have a port from 1 to 65535 that has no leading zero and is not the scheme's default. Any other value aborts startup, including an origin with another scheme, such as a browser extension's.
  • metrics.username: variable PIXA_METRICS_USERNAME, default empty. The username for /metrics. pixa serves /metrics, and measures every HTTP request, only when the username and the password are both set. Setting one without the other aborts startup.
  • metrics.password: variable PIXA_METRICS_PASSWORD, default empty. The password for /metrics. Set it together with the username.
  • sentry_dsn: variable PIXA_SENTRY_DSN, default empty. Sentry is an error reporting service, and the DSN is the URL it gives you. A DSN turns error reporting on. It must be a URL with a scheme and a host, or empty.
  • debug: variable PIXA_DEBUG, default false. Turns on debug logging and plain-HTTP local development (see Routes).
  • maintenance_mode: variable PIXA_MAINTENANCE_MODE, default false. While true, /v1/image/ and /v1/e/ answer every GET and HEAD request with 503, a Retry-After header of 300 seconds and a JSON error body. The health check still answers 200 and reports "maintenance_mode": true. It stays 200 because the image's Docker HEALTHCHECK requests it, and upaas marks a deploy failed when its container is unhealthy. The login and generator pages and /metrics keep working.

configs/config.example.yml names every setting with its default.

Architecture

  • Dependency injection: fx (go.uber.org/fx), a library that builds the program's parts in dependency order and starts and stops them. internal/app puts every part of pixad in one fx graph. internal/providers builds the image cache, the image proxy and the other parts that serve images and logins. Each setting's default is declared once, in internal/config.

  • Packages: these packages under internal/ work with the concepts above:

    • handlers: the routes, which read the image request from the URL, check the signed or encrypted URL, and make the login session
    • imagerequest: the image request, with the source key and the variant key
    • imageproxy: the image proxy
    • imgcache: the image cache, which keeps its sources in one FileStore and its variants in another
    • httpfetcher: fetching sources from upstream hosts
    • magic: reading an image's format from its first bytes
    • imageprocessor: making a variant from a source with libvips
    • signature: checking the sig of a signed URL
    • encurl: making and reading the tokens of encrypted URLs
    • seal: the keys derived from signing_key, and the encryption of those tokens
    • session: the login session
  • HTTP router: chi (github.com/go-chi/chi/v5)

  • Image processing: govips, a Go wrapper for libvips

  • Database: SQLite via modernc.org/sqlite

  • Static assets: embedded in the binary via //go:embed

  • Metrics: Prometheus, at /metrics. Prometheus is a monitoring system that reads metrics from that route. pixa serves /metrics and measures HTTP requests only when metrics.username and metrics.password are set. /metrics holds:

    • the duration of HTTP requests
    • the size of HTTP responses
    • the HTTP requests in flight
    • the Go runtime and process metrics
    • pixa_cache_hits_total: image requests served from the cache
    • pixa_cache_misses_total: image requests not served from the cache
    • pixa_upstream_fetches_total: fetches of a source image that read any bytes
    • pixa_upstream_fetch_bytes_total: the bytes those fetches read
    • pixa_transforms_total: images converted

    A request's handler label is the pattern of the route it matches, such as /v1/image/*, never its path. Requests that match no route share the label unmatched: those the router answers 404 or 405, and CORS preflights. A request's method label is its method, for these nine: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT and TRACE. Any other method is labelled other.

  • Logging: the standard library's slog, as JSON on standard output, or as text when standard output is a terminal.

Entrypoints

This repository adheres to the Scripts to Rule Them All standard. The scripts in script/ are the entrypoints for the development workflow, and most Makefile targets are thin shims that call them. We provide:

  • script/bootstrap — install git, make, Go, Node, Yarn and prettier, and download the Go modules. It can be run again at any time. With --cgo, it installs a C compiler, pkg-config, libvips (with its JPEG XL support) and libheif instead of Node, Yarn and prettier. Compiling pixa outside Docker, as make build and script/bench do, needs those. Docker itself must already be installed.
  • script/setup — make a fresh clone ready for development: script/bootstrap, then script/install-precommit.
  • script/projectname — output the project name (pixa).
  • script/test — run the test suite: build the test phase of the Dockerfile, writing no image.
  • script/lint — run golangci-lint: build the lint phase of the Dockerfile, writing no image. The linter never runs on the host.
  • script/fmt — format the Go code with gofmt and the markdown with prettier (writes).
  • script/fmt-check — check the same formatting (read-only), on the host.
  • script/check — run script/test, script/lint and script/fmt-check.
  • script/docker — build the Docker image tagged via script/projectname, with the version from git describe. The image's build stage depends on the lint and test phases, so this runs them too.
  • script/docker-smoke — build the image, start it, and wait for it to be healthy.
  • script/loadtest — measure pixad's throughput, latency and peak memory. It is a benchmark, not part of script/check (see Load Test).
  • script/bench — run the Go benchmark that serves cache hits in parallel through the image handler, with Go on the host after script/bootstrap --cgo. It is a benchmark, not part of script/check (see Load Test).
  • script/cibuild — the CI entrypoint: run script/bootstrap (without --cgo), then script/check, then build the image as script/docker does.
  • script/precommit — the pre-commit checks: the go mod tidy guard, then script/check.
  • script/install-precommit — install the git pre-commit hook that runs script/precommit.

Every docker build in these scripts passes a new value of the CACHEBUST build argument. Every stage of the Dockerfile declares it with an ARG CACHEBUST line. So every RUN step after that line, the lint and test steps included, runs on every build instead of coming from the build cache. The steps above that line install system packages and download the Go modules. Docker keeps their layers until the base image, the step itself, go.mod or go.sum changes.

Go's build cache is kept from build to build: the steps that compile mount it, so a package whose inputs are unchanged is not compiled again. The test step runs go test, and reruns it with -v after a failure. Both runs pass -count=1, so no stored test result is replayed. The lint step also mounts golangci-lint's cache. That cache reuses a package's findings only while its files, its imports, go.mod, .golangci.yml and the golangci-lint version are unchanged.

The test phase runs on the Debian Go image, with Debian's libvips and libheif. The image pixa ships is built and runs on Alpine.

These scripts are the standard copies from sneak/prompts, kept identical to them:

  • script/check
  • script/cibuild
  • script/docker
  • script/lint
  • script/test
  • script/setup
  • script/install-precommit

script/fmt and script/fmt-check are the standard copies with pixa's gofmt step kept before prettier. prettier formats the markdown only, not the HTML templates, as it cannot parse a Go template action inside a tag.

Load Test

script/loadtest (or make loadtest) measures how fast pixad answers and how much memory it uses. It is a benchmark, not a check: script/check does not run it. It needs Docker and Go.

script/loadtest           # 10 seconds per scenario, 4 clients
script/loadtest 30s 32    # 30 seconds per scenario, 32 clients

It builds the image with script/docker. It also builds the load tool, vegeta, from a pinned commit. Each scenario starts a new pixad container and a new origin container, cmd/loadtest-origin. The origin is an upstream host that answers every path with the same generated 1600x1200 JPEG. vegeta then sends requests from the given number of clients. Each client sends its next request as soon as its last one is answered. Every request asks for an image resized to 400x300 WebP:

  • hit: the same image every time, put in the cache first;
  • miss: a new source image every time, so pixad fetches and converts each one;
  • herd: every client asks for each new source image at the same moment. The clients then share one fetch and one conversion (see Limits).

pixad refuses upstream hosts with private or local addresses, so the containers share a Docker network in 203.0.113.0/24, a range set aside for documentation. A second run on the same Docker host while one is going fails, as it cannot create that network.

For each scenario the script prints vegeta's report and two lines of its own:

  • Requests [total, rate, throughput]: the requests sent, how many were sent per second, and how many were answered successfully per second;
  • Latencies [min, mean, 50, 90, 95, 99, max]: the time from sending a request to the end of its answer. 50, 90, 95 and 99 are the 50th, 90th, 95th and 99th percentiles;
  • Status Codes and Error Set: anything other than 200 means the other numbers are not for the scenario described, such as 503 when pixad was busy;
  • Bytes In: 0, as vegeta is told not to keep the images it receives;
  • pixad peak memory (VmHWM): the peak resident memory of the pixad process since its container started, in kB; for hit it includes the request that put the image in the cache;
  • requests to the origin: the fetches pixad made. That is one for hit, one per request for miss, and one per image for herd: the requests sent divided by the number of clients.

The numbers depend on the machine and on whatever else runs on it.

script/bench (or make bench) measures cache hits alone, without the network and with no pixad process. A Go benchmark in internal/handlers serves one cached image through the /v1/image/ handler from several goroutines at once, one per CPU. A goroutine is a Go thread. The benchmark reports the hits served per second as hits/s. Each run starts with an empty cache and an empty database, a SQLite file of its own, and puts the image in the cache before it measures. The benchmark fails if serving the hits ran any statement on the database. Like the load test it is not part of script/check. It runs Go on the host, as the load test does, and compiles pixa there. So it needs the C compiler and libraries that script/bootstrap --cgo installs, but not Docker.

TODO

See TODO.md for the full prioritized task list.

License

GPL-3.0. See LICENSE.

Author

@sneak

S
Description
Pixa is a caching and resizing HTTP reverse proxy designed for serving images.
https://git.eeqj.de/sneak/pixa
Readme GPL-3.0
4.3 MiB
Languages
Go 94.6%
Shell 2.6%
HTML 1.9%
Dockerfile 0.5%
Makefile 0.4%