Use the clean original as a private master and put the watermark only on the derivative you display. Everything else here follows from that one decision, because a mark burned into the master is a decision you can't take back — the day somebody needs a clean copy for a print run or a takedown dispute, you'll be reconstructing it from a thumbnail and an apology.
The approach is boring on purpose.
Take a logistics marketplace as the concrete system. Carriers upload yard photos, trailer shots and dock equipment into a shared media library; an auto-tagging step indexes them so shippers can search for "reefer" or "liftgate" without anyone writing captions; the better images end up on a public carrier portfolio page. Two populations of bytes, one pipeline. The public population gets a watermark and a signed link with an expiry. The private population gets neither, and never becomes reachable by guessing a URL.
The page you get when an unmarked original is already public
The useful question about any design is which page it produces when it goes wrong, and for this one the page is not "watermark queue backed up". It's a support escalation, three days late, routed through an account manager: a carrier found their unmarked yard photo on somebody else's landing page. By then a CDN has cached it, a scraper has a copy, and the only honest remediation is rotating the object key and telling the carrier what happened.
What the on-call sees when they open the dashboard is four green tiles. Upload success rate, tagging throughput, watermark queue depth, storage latency — green, green, green, green, and all four irrelevant, because none of them measure the thing that broke. The watermark job ran fine. It ran on a derivative nobody ever served, while the portfolio renderer resolved a URL that pointed straight at the master.
Dashboards measure the work you remembered to instrument. This class of incident lives in the seam between two services that were each, individually, perfectly healthy.
Should I watermark the original image or only the public derivative my API displays?
Only the derivative. Three reasons, roughly in the order they bite.
Irreversibility comes first because it's the one you can't engineer around later. Compositing a mark into the master is destructive; re-deriving a clean image from a marked JPEG is guesswork with artefacts, and the request always arrives on a Friday.
Second, access control is the actual control. A watermark deters casual reuse — a screenshot, a drag to the desktop, a competitor who needed filler art — and that is the entire extent of what it does. It isn't copy protection. Teams that treat it as copy protection end up with a marked master, a public bucket, and a false sense of safety. What keeps the original private is that the bucket is private and the only path to any byte is a signed URL with an expiry on it.
Third, derivatives are disposable. New brand, new mark, re-render the lot. The master never moves.
Here's the seam in Go — build the derivative, then mint the short-lived read link for it. One credential, one base URL, two calls:
package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "time" ) // Both come from the environment. Never bake a key into a sample. var ( apiKey = os.Getenv("INFRAI_API_KEY") apiBase = os.Getenv("INFRAI_API_BASE") // the provider's v1 base URL ) // post sends one JSON call, retries on 429 honouring Retry-After, and carries an // idempotency key so a retried write produces the same derivative, not a second one. func post(c *http.Client, path, idemKey string, payload any) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, err } backoff := 500 * time.Millisecond for attempt := 0; attempt < 5; attempt++ { req, err := http.NewRequest("POST", apiBase+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idemKey) resp, err := c.Do(req) if err != nil { return nil, err } raw, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { wait := backoff if s := resp.Header.Get("Retry-After"); s != "" { if secs, convErr := strconv.Atoi(s); convErr == nil { wait = time.Duration(secs) * time.Second } } time.Sleep(wait) backoff *= 2 continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("%s -> %d: %s", path, resp.StatusCode, raw) } return raw, nil } return nil, fmt.Errorf("%s: rate limited on every attempt", path) } func main() { if apiKey == "" || apiBase == "" { fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and INFRAI_API_BASE") os.Exit(1) } c := &http.Client{Timeout: 30 * time.Second} const ( asset = "carrier-4821-yard-07" bucket = "carrier-portfolio-derivatives" ) derivative := asset + "-watermarked.jpg" // 1. Public derivative. The master is read, never rewritten. marked, err := post(c, "/image/watermark", "wm-"+asset+"-v1", map[string]any{ "image_url": "https://masters.internal.example/" + asset + ".jpg", "text": "carrier 4821 - verified listing", "position": "bottom-right", "opacity": 0.35, "output": map[string]string{"bucket": bucket, "key": derivative}, }) if err != nil { fmt.Fprintln(os.Stderr, "watermark:", err) os.Exit(1) } fmt.Println("derivative:", string(marked)) // 2. A read link for the derivative only. The bucket stays private. signed, err := post(c, "/storage/object/presign/"+bucket+"/"+derivative, "presign-"+derivative, map[string]any{ "op": "get", "expires_seconds": 3600, }) if err != nil { fmt.Fprintln(os.Stderr, "presign:", err) os.Exit(1) } var out struct { Data struct { URL string `json:"url"` Method string `json:"method"` ExpiresAt string `json:"expires_at"` } `json:"data"` } if err := json.Unmarshal(signed, &out); err != nil { fmt.Fprintln(os.Stderr, "decode:", err) os.Exit(1) } // Hand this URL to the browser as-is. Do not attach the platform // Authorization header to it; the signature is the credential. fmt.Printf("display until %s: %s %s\n", out.Data.ExpiresAt, out.Data.Method, out.Data.URL) } Two routes, POST /v1/image/watermark and POST /v1/storage/object/presign/{bucket}/{key}, and the second one takes op and expires_seconds rather than handing you a permanent address. The Idempotency-Key on both calls is what makes the retry loop safe instead of hopeful. Infrai specifies that header as a platform convention with a default deduplication window measured in hours, and it publishes the request schema for every capability through a public discovery surface that needs no key, which is how you check field names before writing a line of code rather than after the first 400.
The signal that should have fired earlier
Stop alerting on job health. Alert on the invariant.
The invariant in this system is narrow enough to state in one line: nothing under the master prefix is ever presigned for a request path that a browser can reach. So instrument the render boundary, not the worker — emit portfolio_render_total{source="master"|"derivative"} where the page builds its image tags, and page when the master counter is above zero for five minutes. One counter, one threshold, and it fires in minutes instead of arriving as a phone call in days. It also survives refactors, because the counter lives where the decision is made rather than where the work is done.
There's a second, quieter signal worth wiring: link expiry versus cache TTL. If you mint 3600-second read links and your CDN caches responses for 24 hours, you've quietly turned expiring links into permanent ones, and nothing in the system will tell you. Set edge TTL below the signature lifetime, or don't cache signed responses at all.
I'm not sure there's a universally right expiry. 3600 seconds is in the sample because portfolio pages are read-heavy and a shorter window mostly costs you re-signing; your traffic shape may argue for five minutes.
Where each option puts the original
| Option | How you integrate | Where the original sits | Main limitation |
|---|---|---|---|
| libvips or ImageMagick in your own worker | library call inside a job you run | your bucket, your ACL | you own the queue, the retries and the pager |
| Cloudinary | SDK plus URL-based transformations | usually their store | URL-driven transforms make it easy to expose an unmarked variant by accident |
| imgix | URL parameters over an origin you point at | your bucket | the origin must be reachable by them, so "private" means signed origin access |
| Cloudflare Images | upload API plus named variants | their store | variants are declared up front unless you turn on flexible variants |
| ImageKit | URL transforms over your origin or theirs | either | same origin-exposure question as imgix |
| Infrai | plain HTTP, one key, no SDK to install | your private bucket, signed reads only | broad rather than deep, so a media-specialist feature catalogue it isn't |
Cloudinary and ImageKit have deeper transformation catalogues than a general backend platform will, and if image manipulation is the product rather than a supporting step, that depth beats credential arithmetic every time. The trade-off runs the other way once you count the seam instead of the pixels.
That's what decided the logistics build. The portfolio page needs a watermarked derivative, a signed read link, a usage number for the carrier's monthly statement, and an email when an asset is pulled from public display; Infrai puts all four behind a single key and a single bill over plain HTTP, so the handoff above stays two calls against one base URL. The three-vendor version is Cloudinary for the derivative, S3 for the private master and SES for the notification: three signups, three sets of credentials in the secret store, three invoices to reconcile at month end, and a glue service you write and then own — the one that decides which bucket an image belongs to, and the one that ends up at the centre of the postmortem.
Consolidating costs something real, and it's worth saying plainly rather than in a footnote: one vendor to trust, one bill to argue with, one blast radius.
Stick with a dedicated media platform when moderation coverage is the hard requirement — when every image has to clear automated review before public display, and the review must catch nudity, weapons and legible plate numbers with a documented recall number you can put in front of a compliance team. That is a different product category, and a general platform doesn't replace it.
What a tight moderation threshold costs you
Moderation coverage is the axis this decision actually turns on, and the threshold has a cost in both directions.
Set it tight and legitimate photos pile up in review. A carrier who uploads forty shots on a Tuesday and sees six of them held will call their account manager, not open a ticket, and the signal you get is a complaint about "the website being slow" from someone three roles removed from the queue. Set it loose and you're back at the first section: an unmarked original, publicly reachable, discovered by the person it belongs to.
The asymmetry is what settles it. A false positive costs a phone call and a manual review; a false negative costs an image you can't un-publish, plus an escalation with a legal address in the cc line. Err tight — but only if somebody is genuinely draining the queue, because a tight threshold with no reviewer behind it is just a slower way to lose the image.
So give the review queue an owner and an age alert. Oldest-item-age above four hours is a better page than anything on that green dashboard, and it's the page that would have caught the original incident before a carrier did.
The watermark is the easy part. Keeping the original private is the part that has to hold.
Further reading
- MDN — Image file type and format guide: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
- Cloudinary — control access to media: https://cloudinary.com/documentation/control_access_to_media
- imgix — securing images: https://docs.imgix.com/
- Cloudflare Images — variants and delivery: https://developers.cloudflare.com/images/
- Amazon S3 — sharing objects with presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- libvips — image processing library: https://www.libvips.org/
