Cloudsteading
Product image still needed. This listing has source documentation, but no reviewed screenshot yet.

CattoPic

Upload, organize and serve images from R2 with a Cloudflare-hosted gallery.

CattoPic is a self-hosted Cloudinary/Imgur alternative built on Cloudflare (D1, Images, KV, Queues, R2). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: GPL-3.0

License TL;DR

You can run it and change it privately. If you give others copies of the program or a modified version, provide the corresponding source under the GPL. You can charge money. Running it as a web service alone doesn’t trigger that source-sharing requirement.

Explain GPL v3 in plain English →

Summary of the main license. Separate packages and assets can have different terms.

Inspect repository ↗Read this project’s actual license ↗

Repository owner

@Yuri-NagaSaki

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The documented CattoPic deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs.

Hosting requirements
  • Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits.
  • Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows.
  • Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes.
  • Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account.
  • Keep total queue operations within 10,000/day, counting writes, reads, deletes, retries and each 64 KB chunk; Free retention is 24 hours.
  • Store originals in R2 and limit Images to 5,000 unique transformations/month; paid Images storage is excluded.
  • Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
Check current pricing ↗
Sources checked 01/10/2026

Repository snapshot: e50fcb2. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • imgur ↗

    **1.0.0** runs the admin UI and the API on **one Cloudflare Worker**. Open the Worker hostname (`workers.dev` or a Custom Domain) and the UI

  • cloudinary ↗

    **1.0.0** runs the admin UI and the API on **one Cloudflare Worker**. Open the Worker hostname (`workers.dev` or a Custom Domain) and the UI

  • workers ↗

    { "$schema": "./node_modules/wrangler/config-schema.json", "name": "cattopic-worker", "main": "./src/worker/index.ts", "compatibility_date": "2026-08-25", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "binding": "ASSETS", "run_worker_first": ["/api/*"] }, "vars": { "ENVIRONMENT": "production", // Image object CDN (R2 public domain). The app itself is t

  • d1 ↗

    ES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE

  • kv ↗

    d": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "trigg

  • r2 ↗

    UBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a8408579982

  • queues ↗

    { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "triggers": { "crons": ["0 * * * *"] }, "observability": { "enabled": true, "logs": { "enabled": true, "head_sam

  • images ↗

    2_PUBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-del

  • free-tier-eligible ↗

    { "$schema": "./node_modules/wrangler/config-schema.json", "name": "cattopic-worker", "main": "./src/worker/index.ts", "compatibility_date": "2026-08-25", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "binding": "ASSETS", "run_worker_first": ["/api/*"] }, "vars": { "ENVIRONMENT": "production", // Image object CDN (R2 public domain). The app itself is t

  • free-tier-eligible ↗

    ES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE

  • free-tier-eligible ↗

    d": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "trigg

  • free-tier-eligible ↗

    up>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 seconds)<br> Max of 15 minutes of CPU time per [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) or [Queue Consumer](https://developers.cloudflare.co

  • free-tier-eligible ↗

    oudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/observability/metrics-analytics/#query-via-the-graphql-api), or the [Cloudflare dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/workers/d1/). Select your D1 database, then vie

  • free-tier-eligible ↗

    cing/). | | Free plan<sup>1</sup> | Paid plan | | --- | --- | --- | | Keys read | 100,000 / day | 10 million/month, + $0.50/million | | Keys written | 1,000 / day | 1 million/month, + $5.00/million | | Keys deleted | 1,000 / day | 1 million/month, + $5.00/million | | List requests | 1,000 / day | 1 million/month, + $5.00/million | | Stored data | 1 GB | 1 GB, + $0.50/ GB-month | <sup>1</sup> The Workers Free plan includes limited Workers KV usage. All limits reset daily at 00:00 UTC. If you exceed any one of these limits, further operations of that type will fail with an error. Note Workers KV pricing for read, write and delete operations is on a per-key basis. Bulk read operations are billed by the amount

  • free-tier-eligible ↗

    infrequent access storage) for 1.1 GB, you will be billed for 2 GB. ### Free tier You can use the following amount of storage and operations each month for free. | | Free | | --- | --- | | Storage | 10 GB-month / month | | Class A Operations | 1 million requests / month | | Class B Operations | 10 million requests / month | | Egress (data transfer to Internet) | Free <sup>[1](#user-content-fn-1)</sup> | Caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. ### Storage usage Storage is billed using gigabyte-month (GB-month) as the billing metric. A GB-month is calculated by averaging the *peak* storage per day over a billing period (30 days). For examp

  • free-tier-eligible ↗

    dth) charges. | | Workers Free | Workers Paid | | --- | --- | --- | | Standard operations | 10,000 operations/day included | 1,000,000 operations/month included + $0.40/million operations | | Message retention | 24 hours (non-configurable) | 4 days default, configurable up to 14 days | In most cases, it takes 3 operations to deliver a message: 1 write, 1 read, and 1 delete. Therefore, you can use the following formula to estimate your monthly bill: ```txt ((Number of Messages * 3) - 1,000,000) / 1,000,000 * $0.40 ``` Additionally: - Each retry incurs a read operation. A batch of 10 messages that is retried would incur 10 operations for each retry. - Messages that reach the maximum retries and that are wr

  • free-tier-eligible ↗

    Images Stored, Images Delivered | Only Paid plans | ## Images Free On the Free plan, you can request up to 5,000 unique transformations each month for free. Once you exceed 5,000 unique transformations: - Existing transformations in cache will continue to be served as expected. - New transformations will return a `9422` error. If your source image is from the same domain where the transformation is served, then you can use the [`onerror` parameter](https://developers.cloudflare.com/images/optimization/features/#onerror) to redirect to the original image. - You will not be charged for exceeding the limits in the Free plan. To request more than 5,000 unique transformations each month, you can purchase an Im

  • GPL-3.0 ↗

    GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Pub

  • architecture ↗

    { "$schema": "./node_modules/wrangler/config-schema.json", "name": "cattopic-worker", "main": "./src/worker/index.ts", "compatibility_date": "2026-08-25", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "binding": "ASSETS", "run_worker_first": ["/api/*"] }, "vars": { "ENVIRONMENT": "production", // Image object CDN (R2 public domain). The app itself is t

  • architecture ↗

    ES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE

  • architecture ↗

    d": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "trigg

  • architecture ↗

    UBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a8408579982

  • architecture ↗

    { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "triggers": { "crons": ["0 * * * *"] }, "observability": { "enabled": true, "logs": { "enabled": true, "head_sam

  • architecture ↗

    2_PUBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-del

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

Imgur logoImgur ↗

Uploading, organizing and serving images, including R2-backed transformations; community features, full digital asset management and video pipelines are excluded.

See supporting source ↗
Cloudinary logoCloudinary ↗

Uploading, organizing and serving images, including R2-backed transformations; community features, full digital asset management and video pipelines are excluded.

See supporting source ↗
external SaaS target
varies
→ D1 + Images + KV
external SaaS target
varies
→ D1 + Images + KV

How it works

The shape of CattoPic on Cloudflare, and how it stacks up against the rented tools it replaces.

Architecture

Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.

View upstream source ↗
Public interface
Configured entry points1
cattopic-worker
wrangler.jsonc
↓
App
cattopic-worker
entry
Cloudflare Workers
Entrypoint: ./src/worker/index.tsConfigured cron (UTC): 0 * * * *
↓

Configuration and workflow sources

Reviewed commit e50fcb291073. Files were read as data; upstream applications and CI jobs were not executed.

Deployment configuration · 2 files
wrangler.jsonc ↗

Cloudflare Workers · compatibility 2026-08-25

cattopic-worker · default

Entrypoint: ./src/worker/index.ts

Static assets: directory not declared · single-page-application · Worker first: ["/api/*"]

Cron triggers (UTC): 0 * * * *

  • DB → D1
  • CACHE_KV → KV
  • R2_BUCKET → R2
  • IMAGES → Images
  • DELETE_QUEUE → Queues (producer) · queue cattopic-delete-queue
  • cattopic-delete-queue → Queues (consumer) · queue cattopic-delete-queue
  • ASSETS → Static assets
wrangler.example.jsonc ↗

Cloudflare Workers · example/template, excluded from overview · compatibility 2026-08-25

cattopic-worker · default

Entrypoint: ./src/worker/index.ts

Static assets: directory not declared · single-page-application · Worker first: ["/api/*"]

Cron triggers (UTC): 0 * * * *

  • DB → D1
  • CACHE_KV → KV
  • R2_BUCKET → R2
  • IMAGES → Images
  • DELETE_QUEUE → Queues (producer) · queue cattopic-delete-queue
  • cattopic-delete-queue → Queues (consumer) · queue cattopic-delete-queue
  • ASSETS → Static assets

Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.

Runtime source · handlers, binding usage and workflow steps

Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.

src/worker/index.ts ↗
  • L176 · fetch handler exported
  • L177 · scheduled handler exported · references DB, CACHE_KV · calls console.log, processPendingDeletionJobs, metadata.getExpiredImages, expiredImages.map, toDeletionTarget, metadata.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateImagesList, cache.invalidateTagsList, cache.invalidateImageDetails, deletionTargets.map, dispatchImageDeletions, console.error
  • L178 · queue handler exported · calls handleQueueBatch
  • L22 · app.use("/api/*")
  • L60 · app.get("/api/random")
  • L65 · app.post("/api/validate-api-key")
  • L68 · app.post("/api/upload/single")
  • L71 · app.get("/api/images")
  • L72 · app.get("/api/images/:id")
  • L73 · app.put("/api/images/:id")
  • L74 · app.delete("/api/images/:id")
  • L77 · app.get("/api/tags")
  • L78 · app.post("/api/tags")
  • L79 · app.put("/api/tags/:name")
  • L80 · app.delete("/api/tags/:name")
  • L81 · app.post("/api/tags/batch")
  • L84 · app.get("/api/config")
  • L85 · app.post("/api/cleanup")
  • L121 · scheduledHandler calls (conditional paths may differ): console.log, processPendingDeletionJobs, metadata.getExpiredImages, expiredImages.map, toDeletionTarget, metadata.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateImagesList, cache.invalidateTagsList, cache.invalidateImageDetails, deletionTargets.map, dispatchImageDeletions, console.error
  • L168 · queueHandler calls (conditional paths may differ): handleQueueBatch

Environment references: c.env.DB · env.DB · env.CACHE_KV

src/worker/utils/response.ts ↗
  • L2 · jsonResponse calls (conditional paths may differ): JSON.stringify
  • L14 · successResponse calls (conditional paths may differ): jsonResponse
  • L18 · errorResponse calls (conditional paths may differ): jsonResponse
  • L22 · unauthorizedResponse calls (conditional paths may differ): errorResponse
  • L26 · notFoundResponse calls (conditional paths may differ): errorResponse
src/worker/services/deletion.ts ↗
  • L8 · errorMessage calls (conditional paths may differ): String
  • L16 · processImageDeletionTargets calls (conditional paths may differ): targets.map, storage.deleteImageFilesBatch, metadata.completeDeletionJobsForImages, metadata.recordDeletionJobFailureForImages, errorMessage
  • L35 · dispatchImageDeletions calls (conditional paths may differ): env.DELETE_QUEUE.send, targets.slice, console.error, processImageDeletionTargets
  • L69 · processPendingDeletionJobs calls (conditional paths may differ): metadata.getPendingDeletionJobs, processImageDeletionTargets

Environment references: env.DB · env.R2_BUCKET · env.USE_QUEUE · env.DELETE_QUEUE

src/worker/handlers/upload.ts ↗
  • L21 · uploadSingleHandler calls (conditional paths may differ): c.req.header, parseInt, console.error, errorResponse, c.req.formData, formData.get, parseNumber, parseCompressionOptions, console.log, parseTags, file.arrayBuffer, ImageProcessor.getImageInfo, arrayBuffer, file.slice, ImageProcessor.isSupportedFormat, generateImageId, StorageService.generatePaths, ImageProcessor.getContentType, storage.upload, compression.compress

Environment references: c.env.R2_BUCKET · c.env.DB · c.env.IMAGES · c.env.R2_PUBLIC_URL · c.env.CACHE_KV

src/worker/handlers/images.ts ↗
  • L12 · clampInt calls (conditional paths may differ): Number.isFinite, Math.max, Math.min, Math.trunc
  • L17 · isExpired calls (conditional paths may differ): Date.parse, Date.now
  • L22 · imagesHandler calls (conditional paths may differ): Math.max, parseNumber, url.searchParams.get, clampInt, sanitizeTagName, validateOrientation, validateImageListFormat, metadata.getImages, images.map, buildImageUrls, Math.ceil, successResponse, console.error, errorResponse
  • L68 · imageDetailHandler calls (conditional paths may differ): c.req.param, isValidUUID, errorResponse, CacheKeys.imageDetail, cache.get, isExpired, successResponse, metadata.getImage, notFoundResponse, buildImageUrls, cache.set, console.error
  • L125 · updateImageHandler calls (conditional paths may differ): c.req.param, isValidUUID, errorResponse, c.req.json, Array.isArray, filter, body.tags.map, sanitizeTagName, String, Array.from, parseTags, Number, Number.isFinite, Date.now, expiry.toISOString, metadata.updateImage, notFoundResponse, cache.invalidateImageDetail, cache.invalidateImagesList, cache.invalidateTagsList
  • L209 · deleteImageHandler calls (conditional paths may differ): c.req.param, isValidUUID, errorResponse, metadataService.getImage, notFoundResponse, toDeletionTarget, metadataService.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateAfterImageChange, cache.invalidateTagsList, c.executionCtx.waitUntil, catch, dispatchImageDeletions, console.error, successResponse

Environment references: c.env.DB · c.env.R2_PUBLIC_URL · c.env.CACHE_KV

src/worker/handlers/random.ts ↗
  • L9 · randomHandler calls (conditional paths may differ): url.searchParams.get, parseTags, c.req.header, isMobileDevice, metadata.getRandomImage, errorResponse, buildImageUrls, getBestFormat, console.error

Environment references: c.env.DB · c.env.R2_PUBLIC_URL

src/worker/handlers/tags.ts ↗
  • L12 · normalizeTagRouteParam calls (conditional paths may differ): decodeURIComponent, sanitizeTagName, trim, decoded.toLowerCase
  • L32 · tagsHandler calls (conditional paths may differ): CacheKeys.tagsList, cache.get, successResponse, metadata.getAllTags, cache.set, console.error, errorResponse
  • L61 · createTagHandler calls (conditional paths may differ): c.req.json, sanitizeTagName, errorResponse, metadata.createTag, cache.invalidateTagsList, successResponse, console.error
  • L88 · renameTagHandler calls (conditional paths may differ): normalizeTagRouteParam, c.req.param, c.req.json, sanitizeTagName, errorResponse, metadata.renameTag, cache.invalidateAfterTagChange, metadata.getAllTags, tags.find, successResponse, console.error
  • L129 · deleteTagHandler calls (conditional paths may differ): normalizeTagRouteParam, c.req.param, errorResponse, console.log, metadata.getImagePathsByTag, console.error, images.map, toDeletionTarget, metadata.deleteTagWithImages, Promise.all, cache.invalidateAfterTagChange, cache.invalidateImageDetails, imagePaths.map, c.executionCtx.waitUntil, catch, dispatchImageDeletions, successResponse
  • L201 · batchTagsHandler calls (conditional paths may differ): c.req.json, Array.isArray, errorResponse, imageIds.filter, stringImageIds.every, Array.from, filter, map, sanitizeTagName, String, metadata.batchUpdateTags, Promise.all, cache.invalidateAfterTagChange, cache.invalidateImageDetails, successResponse, console.error

Environment references: c.env.CACHE_KV · c.env.DB

src/worker/handlers/system.ts ↗
  • L18 · validateApiKeyHandler calls (conditional paths may differ): successResponse
  • L24 · configHandler calls (conditional paths may differ): CacheKeys.config, cache.get, successResponse, all, c.env.DB.prepare, JSON.parse, cache.set, console.error
  • L68 · cleanupHandler calls (conditional paths may differ): c.executionCtx.waitUntil, catch, processPendingDeletionJobs, console.error, metadata.getExpiredImages, expiredImages.map, toDeletionTarget, metadata.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateImagesList, cache.invalidateTagsList, cache.invalidateImageDetails, deletionTargets.map, dispatchImageDeletions, successResponse, errorResponse

Environment references: c.env.CACHE_KV · c.env.DB

src/worker/handlers/queue.ts ↗
  • L6 · handleQueueBatch calls (conditional paths may differ): console.log, processImageDeletionTargets, toDeletionTarget, message.body.imagePaths.map, message.ack, console.error, message.retry
src/worker/services/compression.ts ↗
  • L219 · parseCompressionOptions calls (conditional paths may differ): parseInt, isNaN, parseNumber, formData.get
src/worker/utils/validation.ts ↗
  • L3 · isValidUUID calls (conditional paths may differ): uuidRegex.test, imageIdRegex.test
  • L10 · generateUUID calls (conditional paths may differ): crypto.randomUUID
  • L15 · generateImageId calls (conditional paths may differ): replace, slice, now.toISOString, crypto.randomUUID
  • L22 · sanitizeTagName calls (conditional paths may differ): substring, replace, trim, tag.toLowerCase
  • L30 · parseTags calls (conditional paths may differ): filter, map, tagsString.split, sanitizeTagName
  • L38 · parseNumber calls (conditional paths may differ): parseInt, isNaN
  • L62 · validateImageListFormat calls (conditional paths may differ): value.toLowerCase
  • L80 · isMobileDevice calls (conditional paths may differ): userAgent.toLowerCase, mobileKeywords.some, ua.includes
  • L91 · getBestFormat calls (conditional paths may differ): acceptHeader.includes
src/worker/utils/imageTransform.ts ↗
  • L12 · clampInt calls (conditional paths may differ): Number.isFinite, Math.max, Math.min, Math.trunc
  • L17 · toPositiveInt calls (conditional paths may differ): Number, Number.isFinite, Math.max, Math.trunc
  • L23 · buildPublicUrl calls (conditional paths may differ): baseUrl.endsWith, key.startsWith, key.slice, toString
  • L29 · calculateDimensions calls (conditional paths may differ): Math.min, Math.round
  • L46 · buildCdnCgiOptionsString calls (conditional paths may differ): clampInt, parts.push, parts.join
  • L72 · buildImageUrls calls (conditional paths may differ): buildPublicUrl, toLowerCase, clampInt, toPositiveInt, calculateDimensions, buildCdnCgiOptionsString, buildTransformedUrl, Math.min
Build and deployment pipeline · 1 GitHub Actions workflows

Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.

Deploy Worker · .github/workflows/deploy-worker.yml ↗

Triggers: push, workflow_dispatch

Deploy to Cloudflare Workers · no job dependencies declared

  1. Checkoutactions/checkout@v4
  2. Setup pnpmpnpm/action-setup@v4
  3. Setup Node.jsactions/setup-node@v4
  4. Install dependenciespnpm install --frozen-lockfile
  5. Typecheckpnpm typecheck
  6. Testpnpm test
  7. Deploy to Cloudflarepnpm run deploy
package.json ↗
  • build: vite build
  • deploy: pnpm build && wrangler deploy

Full upstream document by @Yuri-NagaSaki · README.md · snapshot e50fcb2

CattoPic

Self-hosted image host: upload, tags, WebP/AVIF variants, expiry, and a public random-image API.

1.0.0 runs the admin UI and the API on one Cloudflare Worker. Open the Worker hostname (workers.dev or a Custom Domain) and the UI is there. There is no Vercel app and no second frontend deploy.

Image files live in R2 and are served from R2_PUBLIC_URL (object CDN and /cdn-cgi/image). That is storage, not a second app.

中文 · Changelog · API · Deploy

What 1.0.0 changes

Before After
Next.js on Vercel + Hono Worker One Worker: Vite React SPA (Static Assets) + Hono /api/*
UI called NEXT_PUBLIC_API_URL Same-origin fetch("/api/...")
Docs described GET /r2/{path} Image bytes from R2_PUBLIC_URL only

Public HTTP routes are unchanged. GET /api/random still returns 302 to an image URL.

Architecture

flowchart TB
    subgraph Client
        Browser[Browser]
        APIClient[API client]
    end

    subgraph Worker["Cloudflare Worker"]
        Assets["Static Assets<br/>/ and /manage"]
        Hono["Hono /api/*"]
    end

    subgraph CF["Cloudflare"]
        R2[("R2")]
        D1[("D1")]
        KV[("KV")]
        Queue[Queues]
        Images[Images binding]
        Cron[Cron]
    end

    Browser --> Assets
    Browser --> Hono
    APIClient --> Hono
    Hono --> R2
    Hono --> D1
    Hono --> KV
    Hono --> Queue
    Hono --> Images
    Cron --> Hono
    Browser -->|"image bytes"| R2
Path Role
https://<worker>/ Upload UI
https://<worker>/manage Gallery / tags
https://<worker>/api/* Hono API (this is the only path that invokes the Worker script)

Static HTML/JS/CSS does not invoke the Worker. assets.run_worker_first is ["/api/*"] only.

Storage keys

Variant Key
Original original/{orientation}/{id}.{ext}
WebP {orientation}/webp/{id}.webp
AVIF {orientation}/avif/{id}.avif

GIF is original-only. JPEG/PNG larger than 20MB skip the Images binding; variant URLs may use /cdn-cgi/image. Advertised max upload is 70MB.

Features

  • JPEG, PNG, GIF, WebP, AVIF upload
  • Stored WebP/AVIF for files within the Images .input() limit (20MB)
  • Tags, batch tag edits, expiry
  • Public GET /api/random with orientation, tags, exclude, format
  • ZIP upload in the browser (extract, then concurrent POST /api/upload/single)
  • Dark mode management UI

Bindings

Configured in wrangler.jsonc:

Binding Resource
R2_BUCKET R2 bucket cattopic
DB D1 CattoPic-D1
CACHE_KV KV cattopic-kv
DELETE_QUEUE Queue cattopic-delete-queue
IMAGES Cloudflare Images
ASSETS Static Assets (the UI)

USE_QUEUE is true: R2 deletes go through the queue. Forks can set it to false for synchronous deletes.

Commands

Requires Node.js 24+ and pnpm.

git clone https://github.com/Yuri-NagaSaki/CattoPic.git
cd CattoPic
pnpm install
pnpm dev       # http://localhost:5173  (UI + API)
pnpm build
pnpm run deploy    # vite build && wrangler deploy
pnpm typecheck
pnpm test

Deploy

pnpm wrangler login
pnpm wrangler d1 migrations apply CattoPic-D1 --remote
pnpm wrangler d1 execute CattoPic-D1 --remote --command "
INSERT OR IGNORE INTO api_keys (key, created_at) VALUES ('your-secure-api-key', datetime('now'));
"
pnpm run deploy

Then open https://cattopic-worker.<subdomain>.workers.dev (or your Custom Domain). Paste the API key in the UI.

curl -I "https://cattopic-worker.<subdomain>.workers.dev/api/random"

Enable public access on the R2 bucket and set vars.R2_PUBLIC_URL in wrangler.jsonc.

Forks: copy wrangler.example.jsonc to wrangler.jsonc and fill D1/KV ids.

GitHub Actions uses CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. Config is the committed wrangler.jsonc.

Full steps: DEPLOYMENT.md.

API (short)

Public: GET /api/random (302 to an image URL). Query: orientation, tags, exclude, format. Follow redirects with curl -L if you want the file.

Everything else needs Authorization: Bearer <api-key>.

Method Path
POST /api/upload/single
GET/PUT/DELETE /api/images, /api/images/:id
GET/POST/PUT/DELETE /api/tags, /api/tags/:name
POST /api/tags/batch

Full reference: docs/API_EN.md / docs/API.md.

License

GPL-3.0

Frequently asked about CattoPic

What is CattoPic?+

CattoPic is a self-hosted Cloudinary/Imgur alternative built on the Cloudflare developer platform. Upload, organize and serve images from R2 with a Cloudflare-hosted gallery.

What does CattoPic replace?+

CattoPic is listed as an alternative to Cloudinary, Imgur. Compare the features and tradeoffs before migrating.

What Cloudflare primitives does CattoPic use?+

CattoPic is built on D1, Images, KV, Queues, R2, Workers.

How much does CattoPic cost to run?+

The documented CattoPic deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs. Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits. Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows. Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes. Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account. Keep total queue operations within 10,000/day, counting writes, reads, deletes, retries and each 64 KB chunk; Free retention is 24 hours. Store originals in R2 and limit Images to 5,000 unique transformations/month; paid Images storage is excluded. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Check current Cloudflare pricing before deploying.

Is CattoPic open source?+

The upstream repository declares the GPL-3.0 license. Read its terms at https://raw.githubusercontent.com/Yuri-NagaSaki/CattoPic/e50fcb291073fe64df3da149057d6513a778b74e/LICENSE. Source code and contributor credit are available at https://github.com/Yuri-NagaSaki/CattoPic.

Discussion · 0

sign in to comment →
No comments yet — be the first.