cd /news/ai-tools/show-hn-fottly-self-hosted-image-api… · home topics ai-tools article
[ARTICLE · art-117702] src=github.com ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Show HN: Fottly – Self-hosted image API with AI background removal

Fottly, an open-source self-hosted image API with AI background removal, was released on Hacker News as a Cloudinary alternative. It supports resizing, format conversion, and background removal, caching results to avoid reprocessing, and uses S3-compatible storage (AWS S3, Cloudflare R2, MinIO) configured via environment variables. The service includes rate limiting (100 req/min per IP by default) and public image delivery, with authentication required only for file management.

read10 min views2 publishedSep 1, 2026
Show HN: Fottly – Self-hosted image API with AI background removal
Image: Michielbdejong (auto-discovered)

A self-hosted image transformation service with built-in AI background removal — an open-source Cloudinary alternative.

Already tested and working: resizes, converts format, removes backgrounds, and caches the result so the same request is never processed twice.

Source images and the result cache are read/written from S3-compatible storage (AWS S3, Cloudflare R2, MinIO...), configured via environment variables.

There's a browsable demo page at demo.html — open it in a browser with the stack running to build transformation URLs interactively.

Variable Description Example (local MinIO)
S3_ENDPOINT
S3 endpoint URL. Leave empty for real AWS S3. http://localhost:9000
S3_REGION
Region. us-east-1
S3_BUCKET
Bucket where source images and the cache (cache/... ) live.
fottly
S3_ACCESS_KEY_ID
Access key. minioadmin
S3_SECRET_ACCESS_KEY
Secret key. minioadmin
S3_FORCE_PATH_STYLE
true for MinIO/backends without virtual-hosted style. false on real AWS S3.
true
API_KEY
Key required for authentication (see the Authentication section below). dev-secret-key
MAX_UPLOAD_SIZE_MB
Maximum request body size, in megabytes (applies to file uploads). 25
RATE_LIMIT_MAX
Maximum requests per IP within the time window (see Rate limiting below). 100
RATE_LIMIT_WINDOW_MS
Rate limit time window, in milliseconds. 60000

There's a .env.example

with these values (the same ones used by docker-compose.yml

).

All routes are globally rate-limited per IP (RATE_LIMIT_MAX

requests per RATE_LIMIT_WINDOW_MS

milliseconds, 100 req/min by default). Exceeding it returns 429

with X-RateLimit-*

headers indicating the limit, remaining requests, and reset time.

The Authorization: Bearer <API_KEY>

header (with the key set in the API_KEY

environment variable) is only required for managing files. Image delivery is public. There's no user database in this version: it's a single shared key.

Route Authentication
GET /health
Public
GET /t/... (image delivery/transformation)
Public
POST /files/... , DELETE /files/... , PUT /files/... (upload/delete/rename)
Requires Authorization

Why /t/... is public: these images are meant to be used in

<img src="...">

on real web pages, and browsers cannot send

Authorization

headers on an <img>

tag — there's no way to do that from plain HTML. This is the same model used by any real image CDN (Cloudinary included): delivery is public (protected at most by the unpredictability of the URL/hash), and only operations that modify files require authentication. If delivery ever needs to be restricted too, the usual approach isn't a header but a signed token in the URL itself — not needed in this MVP yet.No header on /files/...

, or a key that doesn't match → 401

with an error message explaining why.

Spin up the app together with a local MinIO (no external account needed):

docker compose up --build

This starts:

minio

— S3-compatible storage, with a web console athttp://localhost:9001(user/password:minioadmin

/minioadmin

)createbuckets

— automatically creates thefottly

bucket on startuprembg

— internal background-removal service (not exposed to the host), with a real healthcheck (curl -f http://localhost:7000/

) —app

waits for it to report healthy before starting, not just startedapp

— the transformation service athttp://localhost:3000, already configured to talk to MinIO and Rembg

Open the MinIO console at

http://localhost:9001and log in withminioadmin

/minioadmin

. - Go into the

fottly

bucket and upload any image ("Upload" → "Upload File" button), e.g.your-image.jpg

. - With the app running (

docker compose up

), visit in your browser:

http://localhost:3000/t/w_400,h_300,f_webp/your-image.jpg

/t/...

is public (see the Authentication section), so no special header is needed — you can paste that URL straight into your browser, or:

curl "http://localhost:3000/t/w_400,h_300,f_webp/your-image.jpg" -o result.webp

You should get the image resized to 400x300 and converted to WebP. If you repeat the same request, the second response comes from the cache (also stored in the bucket, under

cache/

).

Alternative without using the web console: if you have the mc

client installed, you can upload the file from the command line:

mc alias set local http://localhost:9000 minioadmin minioadmin
mc cp your-image.jpg local/fottly/your-image.jpg
npm install

You need accessible S3-compatible storage (for example, the MinIO from docker-compose.yml

, started with docker compose up minio createbuckets

). Copy .env.example

to .env

, adjust values if needed, and export the variables before starting the server:

cp .env.example .env
export $(cat .env | grep -v '^#' | xargs)   # bash/macOS/Linux
npm run dev

On PowerShell:

Copy-Item .env.example .env
Get-Content .env | Where-Object { $_ -notmatch '^#' -and $_ } | ForEach-Object {
  $name, $value = $_.Split('=', 2)
  Set-Item "Env:$name" $value
}
npm run dev

Then request, for example (/t/...

is public, no header needed):

curl "http://localhost:3000/t/w_400,h_300,f_webp/your-image.jpg" -o result.webp

URL transformation syntax (same idea as Cloudinary/Openinary):

w_400

— width in pixelsh_300

— height in pixelsf_webp

— output format (webp

,avif

,jpeg

,png

,tiff

)q_80

— quality (0-100)r_90

— rotation in degrees, clockwise (seeRotationbelow)grayscale

— converts the image to grayscale (simple flag, no value)

They can be combined, separated by commas: w_800,h_600,f_avif,q_75

.

c_fill

(default ifc_

isn't set, same as before): crops the image to fill exactlyw

xh

.c_fit

: keeps the whole image without cropping, fitting it withinw

xh

(may end up smaller on one axis).

curl "http://localhost:3000/t/w_300,h_300,c_fit,f_webp/your-image.jpg" -o result-fit.webp

Add r_<degrees>

to rotate the image clockwise by that many degrees. r_90

, r_180

, and r_270

are the common cases and are lossless/exact — no part of the canvas is exposed. Arbitrary angles are also supported (e.g. r_45

); in that case the corners exposed by the rotation are filled with a fully transparent background, the same convention already used by bg_remove

: it comes through as real transparency on alpha-capable formats (webp

, png

, tiff

), and gets flattened to black on jpeg

(which has no alpha channel).

curl "http://localhost:3000/t/r_90,f_webp/photo.jpg" -o rotated.webp

Rotation happens before resize/crop, so it combines normally with w_

/h_

/c_

/f_

.

Add the grayscale

flag (no value, same style as bg_remove

) to convert the image to grayscale.

curl "http://localhost:3000/t/w_400,grayscale,f_webp/photo.jpg" -o gray.webp

Add the bg_remove

parameter to the transform list to remove the image's background (leaves the subject cut out on a transparent background) using Rembg, run as an internal service in docker-compose.yml

(not exposed to the host, only app

talks to it over the internal Docker network).

/t/bg_remove/photo.jpg
/t/w_400,bg_remove,f_webp/photo.jpg

bg_remove

internally resizes the image to a maximum of 1600px on the longer side before sending it to Rembg (see below for why), and then the rest of the transforms (resize, format, quality) are applied to the already background-free result. Use an output format with an alpha channel (png

or webp

) if you want to keep the transparency; with jpeg

it will be flattened onto a black background, since JPEG doesn't support transparency.

The first request with bg_remove

can take several seconds (Rembg processes the image at full resolution); subsequent identical requests are served from cache like any other transform.

Model used: by default Rembg uses the u2net

model, which in real testing cropped thin structures poorly (for example, it cut off a leg in a photo of a standing character). The service is configured to use isnet-general-use

with alpha matting enabled instead, which in the same test kept both legs and the tail feathers with much more detail. birefnet-general

(more accurate in theory) was also tried, but its model (~1GB) crashed the container from lack of memory (OOMKilled

), so it was ruled out.

Resolution limit: alpha matting scales poorly in memory — in real testing, a 4000x3000 (12MP) photo crashed the container with OOMKilled

, while a ~700px image worked fine. That's why bg_remove

resizes the image to a maximum of 1600px on the longer side before sending it to Rembg (see src/rembg.ts); it doesn't noticeably affect cutout quality, but it avoids the crash.

Known limitation: bg_remove

isolates one main subject against everything else — it doesn't decide which other objects in the photo are "important" enough to keep. If the subject is holding or standing next to another object (a surfboard, a tool, etc.), that object gets removed along with the rest of the background. Automatically detecting "what matters in each photo" beyond the main subject isn't something a background-removal model can solve on its own; it would require a different approach (object detection plus a relevance criterion, typically with a vision/language model) that's out of scope for this phase.

Add wm_<name-of-the-watermark-file-in-the-bucket>

to overlay that image (top-left corner by default), at ~60% opacity and scaled to ~20% of the result's width. The watermark file must already exist in the bucket (upload it just like any other image).

curl "http://localhost:3000/t/w_600,wm_logo.png,f_webp/your-image.jpg" -o with-watermark.webp

If the watermark image doesn't exist in the bucket, it returns 400

with a clear message.

Position ( wg_): by default the watermark goes in the top-left corner (

northwest

). To change it, add wg_<position>

with one of these values: north

, northeast

, east

, southeast

, south

, southwest

, west

, northwest

, center

(these are the same names Sharp uses internally).

curl "http://localhost:3000/t/w_600,wm_logo.png,wg_center,f_webp/your-image.jpg" -o with-watermark-centered.webp

Size ( ws_) and opacity (wo_): by default the watermark is scaled to 20% of the result's width at 60% opacity. Override either with a percentage from 1 to 100:

curl "http://localhost:3000/t/w_600,wm_logo.png,ws_35,wo_90,f_webp/your-image.jpg" -o with-watermark-custom.webp

If Sharp can't process the requested file as an image (PDF, audio, a corrupted file...) or it's an animated/multi-frame format (animated GIF, animated WebP) — which Sharp would decode without error but lose the animation — it's served as-is instead of failing, with the appropriate Content-Type

based on its extension.

curl "http://localhost:3000/t/w_100,h_100/document.pdf" -o document.pdf

Real note from testing: with the Sharp build this project uses, SVG and static GIF (single frame) are processed normally (resized/ converted like any other image) — they don't trigger the passthrough, contrary to what their extension might suggest. Only formats Sharp truly can't decode (PDF, audio...) and animated/multi-frame formats are served as-is, to avoid losing the animation.

Upload a file (raw binary body, Content-Type

set to the file's own type). If a file with the same name already exists, its old cache is cleared so stale transforms of the previous content aren't served afterwards:

curl -X POST -H "Authorization: Bearer dev-secret-key" \
  -H "Content-Type: image/jpeg" \
  --data-binary @your-image.jpg \
  "http://localhost:3000/files/your-image.jpg"

Delete a file (and all its derived cache entries):

curl -X DELETE -H "Authorization: Bearer dev-secret-key" \
  "http://localhost:3000/files/your-image.jpg"

Rename/move a file (and clear its old cache):

curl -X PUT -H "Authorization: Bearer dev-secret-key" \
  -H "Content-Type: application/json" \
  -d '{"newFilename":"new-name.jpg"}' \
  "http://localhost:3000/files/your-image.jpg"

Both return 404

if the file doesn't exist, and PUT

returns 400

if newFilename

is missing from the body.

How cache invalidation works: a file's cache is stored under cache/<filename>/<hash-of-the-transforms>

instead of a flat hash, so deleting or renaming a file can wipe all of its derived cache in one go (everything under that prefix is listed and deleted).

  • Phase 0: local image transformation (resize, convert format, cache) — tested and working
  • Phase 1: S3-compatible storage (AWS S3 / Cloudflare R2 / MinIO) — tested with MinIO via Docker Compose
  • Phase 2: API key authentication — Authorization: Bearer <API_KEY>

header on/files/...

(management);/health

and/t/...

(delivery) are public - Phase 3: AI background removal (Rembg) — bg_remove

URL parameter, internal service via Docker Compose - Phase 4: crop mode ( c_fill

/c_fit

), file management (upload/delete/rename with cache invalidation), watermark (wm_

), passthrough for unsupported/animated formats - Phase 5: stability hardening — file upload endpoint, request size limit ( MAX_UPLOAD_SIZE_MB

), per-IP rate limiting, realrembg

healthcheck

AGPL-3.0 — see the LICENSE file. If someone modifies this project and offers it as a network service, they're required to publish the source code of their modifications.

── more in #ai-tools 4 stories · sorted by recency
── more on @fottly 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/show-hn-fottly-self-…] indexed:0 read:10min 2026-09-01 ·