{"slug": "show-hn-fottly-self-hosted-image-api-with-ai-background-removal", "title": "Show HN: Fottly – Self-hosted image API with AI background removal", "summary": "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.", "body_md": "A self-hosted image transformation service with built-in AI background removal — an open-source Cloudinary alternative.\n\nAlready tested and working: resizes, converts format, removes backgrounds, and caches the result so the same request is never processed twice.\n\nSource images and the result cache are read/written from S3-compatible storage (AWS S3, Cloudflare R2, MinIO...), configured via environment variables.\n\nThere's a browsable demo page at [ demo.html](/Noah-1919/Fottly/blob/main/demo.html) — open it in a\nbrowser with the stack running to build transformation URLs interactively.\n\n| Variable | Description | Example (local MinIO) |\n|---|---|---|\n`S3_ENDPOINT` |\nS3 endpoint URL. Leave empty for real AWS S3. | `http://localhost:9000` |\n`S3_REGION` |\nRegion. | `us-east-1` |\n`S3_BUCKET` |\nBucket where source images and the cache (`cache/...` ) live. |\n`fottly` |\n`S3_ACCESS_KEY_ID` |\nAccess key. | `minioadmin` |\n`S3_SECRET_ACCESS_KEY` |\nSecret key. | `minioadmin` |\n`S3_FORCE_PATH_STYLE` |\n`true` for MinIO/backends without virtual-hosted style. `false` on real AWS S3. |\n`true` |\n`API_KEY` |\nKey required for authentication (see the Authentication section below). | `dev-secret-key` |\n`MAX_UPLOAD_SIZE_MB` |\nMaximum request body size, in megabytes (applies to file uploads). | `25` |\n`RATE_LIMIT_MAX` |\nMaximum requests per IP within the time window (see Rate limiting below). | `100` |\n`RATE_LIMIT_WINDOW_MS` |\nRate limit time window, in milliseconds. | `60000` |\n\nThere's a `.env.example`\n\nwith these values (the same ones used by `docker-compose.yml`\n\n).\n\nAll routes are globally rate-limited per IP (`RATE_LIMIT_MAX`\n\nrequests per\n`RATE_LIMIT_WINDOW_MS`\n\nmilliseconds, 100 req/min by default). Exceeding it\nreturns `429`\n\nwith `X-RateLimit-*`\n\nheaders indicating the limit, remaining\nrequests, and reset time.\n\nThe `Authorization: Bearer <API_KEY>`\n\nheader (with the key set in the\n`API_KEY`\n\nenvironment variable) is only required for **managing** files.\nImage **delivery** is public. There's no user database in this version:\nit's a single shared key.\n\n| Route | Authentication |\n|---|---|\n`GET /health` |\nPublic |\n`GET /t/...` (image delivery/transformation) |\nPublic |\n`POST /files/...` , `DELETE /files/...` , `PUT /files/...` (upload/delete/rename) |\nRequires `Authorization` |\n\n**Why /t/... is public:** these images are meant to be used in\n\n`<img src=\"...\">`\n\non real web pages, and browsers **cannot** send\n\n`Authorization`\n\nheaders on an `<img>`\n\ntag — there's no way to do that from\nplain HTML. This is the same model used by any real image CDN (Cloudinary\nincluded): delivery is public (protected at most by the unpredictability\nof the URL/hash), and only operations that modify files require\nauthentication. If delivery ever needs to be restricted too, the usual\napproach isn't a header but a signed token in the URL itself — not needed\nin this MVP yet.No header on `/files/...`\n\n, or a key that doesn't match → `401`\n\nwith an\nerror message explaining why.\n\nSpin up the app together with a local MinIO (no external account needed):\n\n```\ndocker compose up --build\n```\n\nThis starts:\n\n`minio`\n\n— S3-compatible storage, with a web console at[http://localhost:9001](http://localhost:9001)(user/password:`minioadmin`\n\n/`minioadmin`\n\n)`createbuckets`\n\n— automatically creates the`fottly`\n\nbucket on startup`rembg`\n\n— internal background-removal service (not exposed to the host), with a real healthcheck (`curl -f http://localhost:7000/`\n\n) —`app`\n\nwaits for it to report healthy before starting, not just started`app`\n\n— the transformation service at[http://localhost:3000](http://localhost:3000), already configured to talk to MinIO and Rembg\n\n-\nOpen the MinIO console at\n\n[http://localhost:9001](http://localhost:9001)and log in with`minioadmin`\n\n/`minioadmin`\n\n. -\nGo into the\n\n`fottly`\n\nbucket and upload any image (\"Upload\" → \"Upload File\" button), e.g.`your-image.jpg`\n\n. -\nWith the app running (\n\n`docker compose up`\n\n), visit in your browser:\n\n```\nhttp://localhost:3000/t/w_400,h_300,f_webp/your-image.jpg\n```\n\n`/t/...`\n\nis public (see the Authentication section), so no special header is needed — you can paste that URL straight into your browser, or:\n\n```\ncurl \"http://localhost:3000/t/w_400,h_300,f_webp/your-image.jpg\" -o result.webp\n```\n\nYou 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\n\n`cache/`\n\n).\n\nAlternative without using the web console: if you have the `mc`\n\nclient installed, you can upload the file from the command line:\n\n```\nmc alias set local http://localhost:9000 minioadmin minioadmin\nmc cp your-image.jpg local/fottly/your-image.jpg\nnpm install\n```\n\nYou need accessible S3-compatible storage (for example, the MinIO from\n`docker-compose.yml`\n\n, started with `docker compose up minio createbuckets`\n\n).\nCopy `.env.example`\n\nto `.env`\n\n, adjust values if needed, and export the\nvariables before starting the server:\n\n```\ncp .env.example .env\nexport $(cat .env | grep -v '^#' | xargs)   # bash/macOS/Linux\nnpm run dev\n```\n\nOn PowerShell:\n\n```\nCopy-Item .env.example .env\nGet-Content .env | Where-Object { $_ -notmatch '^#' -and $_ } | ForEach-Object {\n  $name, $value = $_.Split('=', 2)\n  Set-Item \"Env:$name\" $value\n}\nnpm run dev\n```\n\nThen request, for example (`/t/...`\n\nis public, no header needed):\n\n```\ncurl \"http://localhost:3000/t/w_400,h_300,f_webp/your-image.jpg\" -o result.webp\n```\n\nURL transformation syntax (same idea as Cloudinary/Openinary):\n\n`w_400`\n\n— width in pixels`h_300`\n\n— height in pixels`f_webp`\n\n— output format (`webp`\n\n,`avif`\n\n,`jpeg`\n\n,`png`\n\n,`tiff`\n\n)`q_80`\n\n— quality (0-100)`r_90`\n\n— rotation in degrees, clockwise (see[Rotation](#rotation-r_)below)`grayscale`\n\n— converts the image to grayscale (simple flag, no value)\n\nThey can be combined, separated by commas: `w_800,h_600,f_avif,q_75`\n\n.\n\n`c_fill`\n\n(default if`c_`\n\nisn't set, same as before): crops the image to fill exactly`w`\n\nx`h`\n\n.`c_fit`\n\n: keeps the whole image without cropping, fitting it within`w`\n\nx`h`\n\n(may end up smaller on one axis).\n\n```\ncurl \"http://localhost:3000/t/w_300,h_300,c_fit,f_webp/your-image.jpg\" -o result-fit.webp\n```\n\nAdd `r_<degrees>`\n\nto rotate the image clockwise by that many degrees.\n`r_90`\n\n, `r_180`\n\n, and `r_270`\n\nare the common cases and are lossless/exact —\nno part of the canvas is exposed. Arbitrary angles are also supported\n(e.g. `r_45`\n\n); in that case the corners exposed by the rotation are filled\nwith a **fully transparent** background, the same convention already used\nby `bg_remove`\n\n: it comes through as real transparency on alpha-capable\nformats (`webp`\n\n, `png`\n\n, `tiff`\n\n), and gets flattened to black on `jpeg`\n\n(which has no alpha channel).\n\n```\ncurl \"http://localhost:3000/t/r_90,f_webp/photo.jpg\" -o rotated.webp\n```\n\nRotation happens before resize/crop, so it combines normally with `w_`\n\n/`h_`\n\n/`c_`\n\n/`f_`\n\n.\n\nAdd the `grayscale`\n\nflag (no value, same style as `bg_remove`\n\n) to convert\nthe image to grayscale.\n\n```\ncurl \"http://localhost:3000/t/w_400,grayscale,f_webp/photo.jpg\" -o gray.webp\n```\n\nAdd the `bg_remove`\n\nparameter to the transform list to remove the image's\nbackground (leaves the subject cut out on a transparent background) using\n[Rembg](https://github.com/danielgatis/rembg), run as an internal service\nin `docker-compose.yml`\n\n(not exposed to the host, only `app`\n\ntalks to it\nover the internal Docker network).\n\n```\n/t/bg_remove/photo.jpg\n/t/w_400,bg_remove,f_webp/photo.jpg\n```\n\n`bg_remove`\n\ninternally resizes the image to a maximum of 1600px on the\nlonger side before sending it to Rembg (see below for why), and then the\nrest of the transforms (resize, format, quality) are applied to the\nalready background-free result. Use an output format with an alpha\nchannel (`png`\n\nor `webp`\n\n) if you want to keep the transparency; with\n`jpeg`\n\nit will be flattened onto a black background, since JPEG doesn't\nsupport transparency.\n\nThe first request with `bg_remove`\n\ncan take several seconds (Rembg\nprocesses the image at full resolution); subsequent identical requests\nare served from cache like any other transform.\n\n**Model used:** by default Rembg uses the `u2net`\n\nmodel, which in real\ntesting cropped thin structures poorly (for example, it cut off a leg in\na photo of a standing character). The service is configured to use\n`isnet-general-use`\n\nwith alpha matting enabled instead, which in the same\ntest kept both legs and the tail feathers with much more detail.\n`birefnet-general`\n\n(more accurate in theory) was also tried, but its\nmodel (~1GB) crashed the container from lack of memory (`OOMKilled`\n\n), so\nit was ruled out.\n\n**Resolution limit:** alpha matting scales poorly in memory — in real\ntesting, a 4000x3000 (12MP) photo crashed the container with `OOMKilled`\n\n,\nwhile a ~700px image worked fine. That's why `bg_remove`\n\nresizes the\nimage to a maximum of 1600px on the longer side before sending it to\nRembg (see [src/rembg.ts](/Noah-1919/Fottly/blob/main/src/rembg.ts)); it doesn't noticeably affect\ncutout quality, but it avoids the crash.\n\n**Known limitation:** `bg_remove`\n\nisolates *one* main subject against\neverything else — it doesn't decide which other objects in the photo are\n\"important\" enough to keep. If the subject is holding or standing next\nto another object (a surfboard, a tool, etc.), that object gets removed\nalong with the rest of the background. Automatically detecting \"what\nmatters in each photo\" beyond the main subject isn't something a\nbackground-removal model can solve on its own; it would require a\ndifferent approach (object detection plus a relevance criterion,\ntypically with a vision/language model) that's out of scope for this\nphase.\n\nAdd `wm_<name-of-the-watermark-file-in-the-bucket>`\n\nto overlay that image\n(top-left corner by default), at ~60% opacity and scaled to ~20% of the\nresult's width. The watermark file must already exist in the bucket\n(upload it just like any other image).\n\n```\n# logo.png already uploaded to the bucket\ncurl \"http://localhost:3000/t/w_600,wm_logo.png,f_webp/your-image.jpg\" -o with-watermark.webp\n```\n\nIf the watermark image doesn't exist in the bucket, it returns `400`\n\nwith\na clear message.\n\n**Position ( wg_):** by default the watermark goes in the top-left\ncorner (\n\n`northwest`\n\n). To change it, add `wg_<position>`\n\nwith one of these\nvalues: `north`\n\n, `northeast`\n\n, `east`\n\n, `southeast`\n\n, `south`\n\n, `southwest`\n\n,\n`west`\n\n, `northwest`\n\n, `center`\n\n(these are the same names Sharp uses\ninternally).\n\n```\ncurl \"http://localhost:3000/t/w_600,wm_logo.png,wg_center,f_webp/your-image.jpg\" -o with-watermark-centered.webp\n```\n\n**Size ( ws_) and opacity (wo_):** by default the watermark is scaled\nto 20% of the result's width at 60% opacity. Override either with a\npercentage from 1 to 100:\n\n```\ncurl \"http://localhost:3000/t/w_600,wm_logo.png,ws_35,wo_90,f_webp/your-image.jpg\" -o with-watermark-custom.webp\n```\n\nIf Sharp can't process the requested file as an image (PDF, audio, a\ncorrupted file...) or it's an **animated/multi-frame** format (animated\nGIF, animated WebP) — which Sharp would decode without error but lose the\nanimation — it's served as-is instead of failing, with the appropriate\n`Content-Type`\n\nbased on its extension.\n\n```\ncurl \"http://localhost:3000/t/w_100,h_100/document.pdf\" -o document.pdf\n```\n\n**Real note from testing:** with the Sharp build this project uses, SVG\nand *static* GIF (single frame) **are processed normally** (resized/\nconverted like any other image) — they don't trigger the passthrough,\ncontrary to what their extension might suggest. Only formats Sharp truly\ncan't decode (PDF, audio...) and animated/multi-frame formats are served\nas-is, to avoid losing the animation.\n\nUpload a file (raw binary body, `Content-Type`\n\nset to the file's own type).\nIf a file with the same name already exists, its old cache is cleared so\nstale transforms of the previous content aren't served afterwards:\n\n```\ncurl -X POST -H \"Authorization: Bearer dev-secret-key\" \\\n  -H \"Content-Type: image/jpeg\" \\\n  --data-binary @your-image.jpg \\\n  \"http://localhost:3000/files/your-image.jpg\"\n```\n\nDelete a file (and all its derived cache entries):\n\n```\ncurl -X DELETE -H \"Authorization: Bearer dev-secret-key\" \\\n  \"http://localhost:3000/files/your-image.jpg\"\n```\n\nRename/move a file (and clear its old cache):\n\n```\ncurl -X PUT -H \"Authorization: Bearer dev-secret-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"newFilename\":\"new-name.jpg\"}' \\\n  \"http://localhost:3000/files/your-image.jpg\"\n```\n\nBoth return `404`\n\nif the file doesn't exist, and `PUT`\n\nreturns `400`\n\nif\n`newFilename`\n\nis missing from the body.\n\n**How cache invalidation works:** a file's cache is stored under\n`cache/<filename>/<hash-of-the-transforms>`\n\ninstead of a flat hash, so\ndeleting or renaming a file can wipe all of its derived cache in one go\n(everything under that prefix is listed and deleted).\n\n- Phase 0: local image transformation (resize, convert format, cache) — tested and working\n- Phase 1: S3-compatible storage (AWS S3 / Cloudflare R2 / MinIO) — tested with MinIO via Docker Compose\n- Phase 2: API key authentication —\n`Authorization: Bearer <API_KEY>`\n\nheader on`/files/...`\n\n(management);`/health`\n\nand`/t/...`\n\n(delivery) are public - Phase 3: AI background removal (Rembg) —\n`bg_remove`\n\nURL parameter, internal service via Docker Compose - Phase 4: crop mode (\n`c_fill`\n\n/`c_fit`\n\n), file management (upload/delete/rename with cache invalidation), watermark (`wm_`\n\n), passthrough for unsupported/animated formats - Phase 5: stability hardening — file upload endpoint, request size limit (\n`MAX_UPLOAD_SIZE_MB`\n\n), per-IP rate limiting, real`rembg`\n\nhealthcheck\n\nAGPL-3.0 — see the [ LICENSE](/Noah-1919/Fottly/blob/main/LICENSE) file. If someone modifies this\nproject and offers it as a network service, they're required to publish\nthe source code of their modifications.", "url": "https://wpnews.pro/news/show-hn-fottly-self-hosted-image-api-with-ai-background-removal", "canonical_source": "https://github.com/Noah-1919/Fottly", "published_at": "2026-09-01 12:32:29+00:00", "updated_at": "2026-09-01 12:53:52.331516+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "developer-tools"], "entities": ["Fottly", "Cloudinary", "AWS S3", "Cloudflare R2", "MinIO"], "alternates": {"html": "https://wpnews.pro/news/show-hn-fottly-self-hosted-image-api-with-ai-background-removal", "markdown": "https://wpnews.pro/news/show-hn-fottly-self-hosted-image-api-with-ai-background-removal.md", "text": "https://wpnews.pro/news/show-hn-fottly-self-hosted-image-api-with-ai-background-removal.txt", "jsonld": "https://wpnews.pro/news/show-hn-fottly-self-hosted-image-api-with-ai-background-removal.jsonld"}}