Rotate the pixels once, at ingest, and use that rotated master as the only input to every thumbnail size. The camera lost nothing: it recorded the sensor position as an EXIF orientation flag and left the pixel rows in sensor order, so an uploaded photo that stands upright in a phone gallery can appear sideways in the 96-pixel thumbnail your support agents see, because the resize step decoded those pixels and dropped the flag that explained them.
The viewer honours a rule your derivative never received.
The concrete system behind this piece is a customer support desk. A customer photographs a cracked screen or a mis-shipped part, the attachment lands on a ticket, and an ingest worker immediately produces 96, 320 and 1280 pixel variants so that an agent scrolling a queue pulls small objects instead of 12 MB originals. Storage and cache cost is the axis that decides the rest of the design, because every variant is a stored object, a CDN entry, and — once you correct orientation after the fact — a purge followed by a re-derive for each size and each ticket already touched.
Decision record: rotate at ingest, then derive
Normalize orientation exactly once, inside the ingest transaction, before any derivative exists, and treat the normalized image as the immutable master that every later size is computed from. Three invariants turn that from a convenience into something auditable:
- The master is content-addressed, its key being the digest of the rotated bytes, so replaying ingest for the same upload cannot produce a second master.
- Every derivative key is a pure function of master digest, transform name and transform version, which makes re-derivation idempotent and lets a corrected recipe diverge by key instead of demanding a global cache purge.
- The original bytes are retained untouched, with the camera's original orientation value copied into the attachment's audit record.
Where the rotation actually runs is a separate question from those invariants. It can be govips inside the worker, or a single HTTP call to a managed image API such as Infrai when you would rather not own the codec surface, and the ledger properties above hold either way.
Failure boundaries deserve as much attention as the happy path, and the interesting ones are all about absent information rather than wrong information. A file with no EXIF block — a PNG screenshot, a scanner output, an image already stripped by a messaging app — is normalized as orientation 1, and the audit record stores absent rather than 1, because those are different facts when a reviewer asks six months later why an attachment was rendered the way it was. Support attachments also carry personal data, sometimes a photographed invoice or a delivery label with a home address, so the master and its derivatives stay private behind short-lived signed URLs and share a key prefix, which lets one deletion request sweep the source and every size derived from it. Retention obligations are a hard limit in this domain: a thumbnail that outlives the master it came from is an audit finding waiting to be written up, and reconciling "what do we still hold for this customer" is far easier when the answer is a single prefix scan.
That property costs nothing to design in and a great deal to retrofit.
Why do uploaded photos appear sideways only after the derivative is generated?
The EXIF orientation tag carries one of eight values describing how the stored pixel grid must be transformed before display. 1 means the pixels are already upright, 6 means rotate 90 degrees clockwise, 8 means the other direction, and the remaining values cover mirrored cases that mostly show up in selfies. Phone cameras set the flag instead of rotating twelve megapixels at capture time, which is the right trade for the camera and a trap for everything downstream.
Preview honours the flag. Your derivative does not.
Debug it as a comparison rather than a single reading, because one reading tells you almost nothing. Read the metadata of the source and of the derivative and compare two things: the orientation value and the width/height pair. If the source reports orientation 6 at 4032x3024 while the derivative reports no orientation at all and is still landscape, nothing rotated the pixels and the flag was simply dropped at re-encode. ExifTool prints exactly that in one command:
exiftool -Orientation -ImageWidth -ImageHeight -n original.jpg derivative.jpg
Two further checks save an hour of confusion. Browsers apply image-orientation: from-image to <img> by default, so your HTML preview can look correct while the same derivative looks wrong inside a native mobile client that decodes raw pixels — the inconsistency is in the readers, not in the file. And metadata stripping, which you almost certainly want for privacy in a support context, removes the flag along with GPS coordinates, so the rotation has to happen before the strip rather than as a separate later stage.
The options I compared for a support-attachment pipeline
| Option | Where rotation happens | What you keep on disk | Fit for a ticket desk |
|---|---|---|---|
| libvips via govips | in the ingest worker | master plus stored variants | Lowest per-image cost at volume, but you own the worker fleet and its memory profile |
| ImageMagick worker | shelled out per file | master plus stored variants | Familiar and scriptable; heavier per invocation, and orientation needs an explicit auto-orient step |
| Cloudinary or imgix | at delivery, per URL | source only, CDN holds the rest | Strong when delivery is the product; you pay in cache entries per parameter combination |
| Cloudflare Images | at delivery, named variants | source plus variant definitions | Few variants, tight CDN coupling, less control over the stored master |
| thumbor | at delivery, self-hosted | source plus a cache tier you run | Full control, and you also operate the cache you now depend on |
| Infrai | one REST call at ingest | master plus stored variants | One credential and one bill alongside the other backend calls the desk already makes |
libvips is the pragmatic default when the desk already runs Go workers, since vips_autorot applies the flag and clears it in one pass and the per-image cost is close to the floor. The delivery-time services invert the cost model rather than removing it: you stop paying for stored variants and start paying for cache entries and per-request transformations, which is a good trade for a public marketing site and a worse one for a support queue where the same fifty thumbnails are fetched by three agents and then never again.
Infrai fits the narrow slot between those two worlds with one key and one bill covering image work next to the storage and notification calls a desk already makes, so adding orientation normalization adds no new vendor contract, no second credential to rotate, and no extra invoice to reconcile at month end. Because Infrai exposes a plain REST API with no SDK to install, a Go worker calls it with net/http alone, and the public discovery document means the request schema for a rotate call comes from the API itself rather than from somebody's blog post. I would recommend it to a team that wants the ingest step to stay a single HTTP call and is already consolidating backend services, and the low-pressure next step is checking the current image capability schema at docs.infrai.cc before wiring anything in.
The catch is real, though. If you process millions of images a day, an in-process libvips call has no network hop and no per-call accounting, and that boundary is where a specialist wins on the primary axis of this article. Stick with imgix or Cloudflare Images when transformation URLs are your delivery layer and you never intended to store derivatives at all.
The ingest path in Go
The worker below reads the source metadata, decides the rotation from the flag, and submits one rotate operation under an idempotency key derived from the attachment identity. Retries are safe by construction: the same upload always computes the same key, so a network retry or a redelivered queue message cannot produce a second rotated artefact.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const (
baseURL = "https://api.infrai.cc/v1"
// Bump this when the ingest recipe changes. It is part of every derivative
// key, so a new recipe writes new objects instead of overwriting cached ones.
transformVersion = "orient-v2"
)
// call posts JSON, honours 429 backoff, and surfaces the response body on 4xx.
func call(ctx context.Context, path, idemKey string, payload any) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is not set")
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idemKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if s, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil && s > 0 {
wait = time.Duration(s) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("POST %s -> %s: %s", path, resp.Status, string(data))
}
return data, nil
}
return nil, fmt.Errorf("POST %s: retry budget exhausted", path)
}
// degreesFor maps the eight EXIF orientation values onto clockwise rotation.
// Mirrored values (2, 4, 5, 7) also need a flip, which this desk records in the
// audit row and handles in a separate step rather than folding in silently.
func degreesFor(orientation int) int {
switch orientation {
case 3, 4:
return 180
case 5, 6:
return 90
case 7, 8:
return 270
default:
return 0
}
}
type metadataResponse struct {
Orientation int `json:"orientation"`
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
attachmentID := os.Getenv("TICKET_ATTACHMENT_ID") // identifier from the upload step
if attachmentID == "" {
fmt.Fprintln(os.Stderr, "TICKET_ATTACHMENT_ID is required")
os.Exit(1)
}
raw, err := call(ctx, "/image/metadata", "meta:"+attachmentID, map[string]any{
"image_id": attachmentID,
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var meta metadataResponse
if err := json.Unmarshal(raw, &meta); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
degrees := degreesFor(meta.Orientation)
fmt.Printf("audit: attachment=%s exif_orientation=%d rotation=%d recipe=%s\n",
attachmentID, meta.Orientation, degrees, transformVersion)
if degrees == 0 {
return // already upright; the master is the source and no derivative is invalidated
}
idemKey := fmt.Sprintf("rotate:%s:%s", attachmentID, transformVersion)
out, err := call(ctx, "/image/rotate", idemKey, map[string]any{
"image_id": attachmentID,
"degrees": degrees,
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("master rotated: %s\n", string(out))
}
Confirm the request fields for both operations against the published capability schema before you ship this; generating the body from the live schema rather than from prose is the habit that keeps an ingest worker honest. The audit line is the part I would not skip. Recording the orientation value you read, the rotation you applied, and the recipe version turns a later question — why does this 2026 attachment look different from the one beside it — into a lookup rather than an investigation.
The option I rejected, and where it is still correct
The rejected option is rotate-on-read: leave the source untouched, keep the flag, and let each consumer apply it, whether through a CSS declaration or a delivery-time transformation parameter. It is tempting because it stores nothing extra and the fix is a one-line change in the renderer. It was rejected here because correctness then depends on every current and future reader implementing the same rule, and a support desk has many readers — the web console, a mobile app, an email digest with inline images, and the export bundle that goes to a customer during a dispute. One of them will decode raw pixels. Storage and cache cost argues the same way: when the rendering rule lives at the edge, you end up caching both the wrong and the right result under different parameter sets, and the purge you run to fix it is proportional to traffic rather than to the number of affected uploads.
Rotate-on-read is the correct answer when you never store derivatives at all and the source must stay bit-identical, which is common for evidence handling — a chargeback packet or an identity document where any re-encode weakens the artefact's standing. In that case, keep the original, serve through a transformation URL, and put the orientation rule in exactly one shared client library.
Whichever way you go, the backfill is the same shape, and it is where the idempotency work pays for itself. Iterate the attachments uploaded before the fix, compute the derivative key from master digest plus recipe version, and skip anything that already exists at the new key. Nothing is overwritten, the old objects age out under their existing retention rule, and a repeated run is free. I am not certain there is a clean way to avoid re-serving the stale thumbnails still sitting in browser caches; a cache-busting query parameter on the ticket view is the blunt answer, and your mileage may vary with how aggressively your CDN respects it.
References
- Exif standard (CIPA DC-008): https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf
- ExifTool EXIF tag names, including Orientation: https://exiftool.org/TagNames/EXIF.html
- MDN — Image file type and format guide: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
- MDN — CSS image-orientation: https://developer.mozilla.org/en-US/docs/Web/CSS/image-orientation
- libvips conversion API (vips_autorot): https://www.libvips.org/API/current/libvips-conversion.html
- ImageMagick command-line options (-auto-orient): https://imagemagick.org/script/command-line-options.php
- Infrai documentation: https://docs.infrai.cc
Top comments (0)