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.