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

OpenShort.link

Manage short links, QR codes and click analytics with a Cloudflare-hosted team dashboard.

OpenShort.link is a self-hosted Bitly/Rebrandly alternative built on Cloudflare (Analytics Engine, D1, KV, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: AGPL-3.0

License TL;DR

You can use and change it, even commercially. If people use your modified version over a network, offer them its corresponding source under the AGPL. Sharing copies has source-sharing duties too. Sharing source code is different from sharing users’ content.

Explain AGPL 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

@idhamsy

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The documented OpenShort.link 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.
  • Keep Analytics Engine within 100,000 data points/day and 10,000 queries/day; one event can write to multiple datasets.
  • 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: bd337fc. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • bitly ↗

    OpenShort.link is an open-source link shortener deployable with a one-click install on Cloudflare, featuring full functionality and working on your existing domain with Cloudflare

  • rebrandly ↗

    OpenShort.link is an open-source link shortener deployable with a one-click install on Cloudflare, featuring full functionality and working on your existing domain with Cloudflare

  • short-io ↗

    OpenShort.link is an open-source link shortener deployable with a one-click install on Cloudflare, featuring full functionality and working on your existing domain with Cloudflare

  • workers ↗

    name = "openshortlink" main = "src/index.ts" compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] # D1 Database # Note: database_id does NOT support environment variable substitution # You must hardcode your actual database ID here (get it from: wrangler d1 list) # This ID is not considered highly sensitive as it requires account access to use [[d1_dat

  • d1 ↗

    ID here (get it from: wrangler d1 list) # This ID is not considered highly sensitive as it requires account access to use [[d1_databases]] binding = "DB" database_name = "openshortlink-db" database_id = "" # Replace with your actual database ID # KV Namespace # Note: id and preview_id do NOT support environment variable substitution # You must hardcode your actual KV namespace IDs here (get them from: wrangler kv:namespace list) [[kv_namespaces]] binding = "CACHE" id = "" # Replace with your a

  • kv ↗

    variable substitution # You must hardcode your actual KV namespace IDs here (get them from: wrangler kv:namespace list) [[kv_namespaces]] binding = "CACHE" id = "" # Replace with your actual KV namespace ID preview_id = "" # Replace with your actual KV preview ID # Analytics Engine [[analytics_engine_datasets]] binding = "ANALYTICS" dataset = "openshortlink-analytics" # Environment variables [vars] ENVIRONMENT = "production" LOG_LEVEL = "info" CACHE_TTL = "604800" MAX_LINKS_PER_DOMAIN = "10000

  • analytics-engine ↗

    h your actual KV namespace ID preview_id = "" # Replace with your actual KV preview ID # Analytics Engine [[analytics_engine_datasets]] binding = "ANALYTICS" dataset = "openshortlink-analytics" # Environment variables [vars] ENVIRONMENT = "production" LOG_LEVEL = "info" CACHE_TTL = "604800" MAX_LINKS_PER_DOMAIN = "10000" # Rate limiting configuration # For development/testing: use shorter windows (60 seconds) # For production: use longer windows (7200 seconds = 2 hours) for security FAILED_AUTH_LIM

  • free-tier-eligible ↗

    name = "openshortlink" main = "src/index.ts" compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] # D1 Database # Note: database_id does NOT support environment variable substitution # You must hardcode your actual database ID here (get it from: wrangler d1 list) # This ID is not considered highly sensitive as it requires account access to use [[d1_dat

  • free-tier-eligible ↗

    ID here (get it from: wrangler d1 list) # This ID is not considered highly sensitive as it requires account access to use [[d1_databases]] binding = "DB" database_name = "openshortlink-db" database_id = "" # Replace with your actual database ID # KV Namespace # Note: id and preview_id do NOT support environment variable substitution # You must hardcode your actual KV namespace IDs here (get them from: wrangler kv:namespace list) [[kv_namespaces]] binding = "CACHE" id = "" # Replace with your a

  • free-tier-eligible ↗

    variable substitution # You must hardcode your actual KV namespace IDs here (get them from: wrangler kv:namespace list) [[kv_namespaces]] binding = "CACHE" id = "" # Replace with your actual KV namespace ID preview_id = "" # Replace with your actual KV preview ID # Analytics Engine [[analytics_engine_datasets]] binding = "ANALYTICS" dataset = "openshortlink-analytics" # Environment variables [vars] ENVIRONMENT = "production" LOG_LEVEL = "info" CACHE_TTL = "604800" MAX_LINKS_PER_DOMAIN = "10000

  • 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 ↗

    ion) | 1 million included per month (+$1.00 per additional million) | | **Workers Free** | 100,000 included per day | 10,000 included per day | Pricing availability Currently, you will not be billed for your use of Workers Analytics Engine. Pricing information here is shared in advance, so that you can estimate what your costs will be once Cloudflare starts billing for usage in the coming months. If you are an Enterprise customer, contact your account team for information about Workers Analytics Engine pricing and billing. ### Data points written Every time you call [`writeDataPoint()`](https://developers.cloudflare.com/analytics/analytics-engine/get-started/#2-write-data-points-from-your-worker) in a Work

  • AGPL-3.0 ↗

    GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it r

  • architecture ↗

    name = "openshortlink" main = "src/index.ts" compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] # D1 Database # Note: database_id does NOT support environment variable substitution # You must hardcode your actual database ID here (get it from: wrangler d1 list) # This ID is not considered highly sensitive as it requires account access to use [[d1_dat

  • architecture ↗

    ID here (get it from: wrangler d1 list) # This ID is not considered highly sensitive as it requires account access to use [[d1_databases]] binding = "DB" database_name = "openshortlink-db" database_id = "" # Replace with your actual database ID # KV Namespace # Note: id and preview_id do NOT support environment variable substitution # You must hardcode your actual KV namespace IDs here (get them from: wrangler kv:namespace list) [[kv_namespaces]] binding = "CACHE" id = "" # Replace with your a

  • architecture ↗

    variable substitution # You must hardcode your actual KV namespace IDs here (get them from: wrangler kv:namespace list) [[kv_namespaces]] binding = "CACHE" id = "" # Replace with your actual KV namespace ID preview_id = "" # Replace with your actual KV preview ID # Analytics Engine [[analytics_engine_datasets]] binding = "ANALYTICS" dataset = "openshortlink-analytics" # Environment variables [vars] ENVIRONMENT = "production" LOG_LEVEL = "info" CACHE_TTL = "604800" MAX_LINKS_PER_DOMAIN = "10000

  • architecture ↗

    h your actual KV namespace ID preview_id = "" # Replace with your actual KV preview ID # Analytics Engine [[analytics_engine_datasets]] binding = "ANALYTICS" dataset = "openshortlink-analytics" # Environment variables [vars] ENVIRONMENT = "production" LOG_LEVEL = "info" CACHE_TTL = "604800" MAX_LINKS_PER_DOMAIN = "10000" # Rate limiting configuration # For development/testing: use shorter windows (60 seconds) # For production: use longer windows (7200 seconds = 2 hours) for security FAILED_AUTH_LIM

What it can replace

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

Bitly logoBitly ↗

Creating/resolving short links, custom slugs, QR codes and click reporting; all vendor integrations, enterprise administration and managed SLAs are excluded.

See supporting source ↗
Rebrandly logoRebrandly ↗

Creating/resolving short links, custom slugs, QR codes and click reporting; all vendor integrations, enterprise administration and managed SLAs are excluded.

See supporting source ↗
Short.io logoShort.io ↗

Creating/resolving short links, custom slugs, QR codes and click reporting; all vendor integrations, enterprise administration and managed SLAs are excluded.

See supporting source ↗
external SaaS target
varies
external SaaS target
varies
external SaaS target
varies

How it works

The shape of OpenShort.link 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
openshortlink
wrangler.toml
↓
App
openshortlink
entry
Cloudflare Workers
Entrypoint: src/index.tsConfigured cron (UTC): 0 0 * * *Configured cron (UTC): 0 */6 * * *
↓

Configuration and workflow sources

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

Partial source coverage: 79 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.

Deployment configuration · 1 files
wrangler.toml ↗

Cloudflare Workers · compatibility 2024-01-01

openshortlink · default

Entrypoint: src/index.ts

Cron triggers (UTC): 0 0 * * * · 0 */6 * * *

  • DB → D1
  • CACHE → KV
  • ANALYTICS → Analytics Engine

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/index.ts ↗
  • L467 · fetch handler exported · calls app.fetch
  • L468 · scheduled handler exported · calls ctx.waitUntil, aggregateYesterday, console.error, processDailyTop100Check, processScheduledStatusCheck
  • L40 · app.use("*")
  • L41 · app.use("*")
  • L49 · app.use("*")
  • L81 · app.use("/api/*")
  • L88 · app.get("/dashboard/health")
  • L94 · app.get("/api/v1/debug/my-location")
  • L118 · app.get("/dashboard/api/v1/debug/my-location")
  • L143 · app.get("/dashboard/__validate__")
  • L152 · app.get("/dashboard/login")
  • L160 · app.get("/dashboard/setup")
  • L183 · app.route("/dashboard/static")
  • L186 · app.get("/dashboard")
  • L217 · app.route("/dashboard/api/v1/auth")
  • L218 · app.route("/dashboard/api/v1/users")
  • L219 · app.route("/dashboard/api/v1/links/import")
  • L220 · app.route("/dashboard/api/v1/links")
  • L221 · app.route("/dashboard/api/v1/domains")
  • L222 · app.route("/dashboard/api/v1/analytics")
  • L223 · app.route("/dashboard/api/v1/tags")
  • L224 · app.route("/dashboard/api/v1/categories")
  • L225 · app.route("/dashboard/api/v1/api-keys")
  • L226 · app.route("/dashboard/api/v1/settings")
  • L229 · app.post("/dashboard/api/v1/auth/setup-auto")
  • L274 · app.get("/dashboard/*")
  • L283 · app.post("/api/v1/auth/setup-auto")
  • L327 · app.route("/api/v1/auth")
  • L328 · app.route("/api/v1/users")
  • L329 · app.route("/api/v1/links/import")
  • L330 · app.route("/api/v1/links")
  • L331 · app.route("/api/v1/domains")
  • L332 · app.route("/api/v1/analytics")
  • L333 · app.route("/api/v1/tags")
  • L334 · app.route("/api/v1/categories")
  • L335 · app.route("/api/v1/api-keys")
  • L336 · app.route("/api/v1/settings")
  • L342 · app.get("*")
  • L396 · scheduled calls (conditional paths may differ): ctx.waitUntil, aggregateYesterday, console.error, processDailyTop100Check, processScheduledStatusCheck
  • L434 · renderBrandedRootPage calls (conditional paths may differ): escapeHtml

Environment references: c.env.DB · c.env.SETUP_TOKEN · c.env.FIRST_USER_USERNAME · c.env.FIRST_USER_PASSWORD · c.env.FIRST_USER_EMAIL

src/middleware/error.ts ↗
  • L14 · errorHandler calls (conditional paths may differ): console.error, c.json, error.issues.map, e.path.join
src/middleware/logger.ts ↗
  • L13 · loggerMiddleware calls (conditional paths may differ): Date.now, next

Environment references: c.env.LOG_LEVEL

src/middleware/csrf.ts ↗

    Environment references: c.env.ENVIRONMENT

    src/services/redirect.ts ↗
    • L27 · mergeQueryParams calls (conditional paths may differ): requestParams.toString, requestParams.forEach, destUrl.searchParams.set, destUrl.toString, console.error
    • L52 · handleRedirect calls (conditional paths may differ): getCachedLink, getLinkBySlug, console.error, Math.floor, Date.now, Promise.all, getGeoRedirects, getDeviceRedirects, getCityRedirects, getOsRedirects, getOgMeta, Object.fromEntries, geoRedirects.map, deviceRedirects.find, cityRedirects.map, osRedirects.find, JSON.parse, setCachedLink, request.headers.get, isBot
    • L344 · extractGeoFromRequest calls (conditional paths may differ): request.headers.get
    • L356 · buildVaryHeader calls (conditional paths may differ): varyValues.push, varyValues.join
    • L373 · resolveDestinationUrl calls (conditional paths may differ): extractGeoFromRequest, geo.country.toUpperCase, geo.city.toLowerCase, request.headers.get, parseUserAgent, rule.city_name.toLowerCase
    • L416 · trackClickAsync calls (conditional paths may differ): request.headers.get, extractGeoFromRequest, parseUserAgent, extractUtmParams, Date.now, hashIpAddress, formatDateForGrouping, extractReferrerDomain, trackClick, incrementClickCount, String, console.error
    src/db/domains.ts ↗
    • L17 · enrichDomain calls (conditional paths may differ): JSON.parse, Array.isArray
    • L46 · retryGetDomain calls (conditional paths may differ): getDomainById
    • L64 · buildDomainSettings calls (conditional paths may differ): JSON.stringify
    • L70 · getDomainById calls (conditional paths may differ): first, bind, env.DB.prepare, enrichDomain
    • L76 · getDomainByName calls (conditional paths may differ): trim, toLowerCase, domainName.replace, first, bind, env.DB.prepare, enrichDomain
    • L93 · createDomain calls (conditional paths may differ): generateId, Date.now, first, bind, env.DB.prepare, trim, toLowerCase, domain.domain_name.replace, invalidateDomainCache, enrichDomain, error.message.includes, getDomainById, getDomainByName, run, retryGetDomain
    • L219 · updateDomain calls (conditional paths may differ): fields.push, values.push, trim, toLowerCase, updates.domain_name.replace, Date.now, first, bind, env.DB.prepare, fields.join, enrichDomain, run, getDomainById, invalidateDomainCache
    • L285 · listDomains calls (conditional paths may differ): params.push, all, bind, env.DB.prepare, domains.map, enrichDomain
    • L303 · getDomainByRoutingPath calls (conditional paths may differ): getCachedDomain, all, bind, env.DB.prepare, setCachedDomain, console.error, path.startsWith, enrichDomain, replace, route.replace, routingPath.startsWith, normalizedPath.startsWith

    Environment references: env.DB

    src/db/settings.ts ↗
    • L31 · getStatusCheckFrequency calls (conditional paths may differ): first, bind, env.DB.prepare, JSON.parse, migrateFrequency
    • L63 · setStatusCheckFrequency calls (conditional paths may differ): Date.now, JSON.stringify, run, bind, env.DB.prepare
    • L90 · getStatusCheckFrequencyOrDefault calls (conditional paths may differ): getStatusCheckFrequency, Date.now
    • L118 · getAnalyticsAggregationEnabled calls (conditional paths may differ): first, bind, env.DB.prepare, JSON.parse
    • L140 · getAnalyticsAggregationEnabledOrDefault calls (conditional paths may differ): Date.now, getAnalyticsAggregationEnabled
    • L161 · setAnalyticsAggregationEnabled calls (conditional paths may differ): Date.now, JSON.stringify, run, bind, env.DB.prepare
    • L180 · getAnalyticsThresholds calls (conditional paths may differ): first, bind, env.DB.prepare, JSON.parse
    • L213 · getAnalyticsThresholdsOrDefault calls (conditional paths may differ): parseInt, getAnalyticsThresholds, Date.now
    • L236 · setAnalyticsThresholds calls (conditional paths may differ): Date.now, JSON.stringify, run, bind, env.DB.prepare
    • L274 · getRootPageSettings calls (conditional paths may differ): first, bind, env.DB.prepare, JSON.parse
    • L299 · getRootPageSettingsOrDefault calls (conditional paths may differ): getRootPageSettings
    • L305 · setRootPageSettings calls (conditional paths may differ): Date.now, JSON.stringify, run, bind, env.DB.prepare

    Environment references: env.DB · env.ANALYTICS_AGGREGATION_ENABLED · env.ANALYTICS_AGGREGATION_THRESHOLD_DAYS · env.ANALYTICS_ENGINE_THRESHOLD_DAYS

    src/utils/html.ts ↗
    • L13 · escapeHtml calls (conditional paths may differ): String, replace, str.replace
    • L33 · html calls (conditional paths may differ): Array.isArray, value.join, escapeHtml
    src/api/links.ts ↗
    • L67 · linksRouter.get("/")
    • L284 · linksRouter.get("/grouped-by-destination")
    • L335 · linksRouter.get("/by-destination")
    • L379 · linksRouter.get("/:id")
    • L441 · linksRouter.post("/og-fetch")
    • L462 · linksRouter.post("/")
    • L640 · linksRouter.put("/:id")
    • L814 · linksRouter.delete("/:id")
    • L854 · linksRouter.post("/bulk")
    • L985 · linksRouter.get("/status/:statusCode")
    • L1026 · linksRouter.post("/check-status")

    Environment references: c.env.DB

    src/api/domains.ts ↗
    • L32 · domainsRouter.get("/")
    • L46 · domainsRouter.get("/:id")
    • L61 · domainsRouter.post("/")
    • L107 · domainsRouter.put("/:id")
    • L159 · domainsRouter.delete("/:id")
    src/api/auth.ts ↗
    • L39 · authRouter.post("/login")
    • L145 · authRouter.post("/register")
    • L262 · authRouter.post("/logout")
    • L298 · authRouter.post("/refresh")
    • L372 · authRouter.get("/me")
    • L395 · authRouter.get("/audit")
    • L430 · authRouter.post("/audit/cleanup")
    • L466 · authRouter.post("/mfa/setup")
    • L510 · authRouter.post("/mfa/verify-setup")
    • L552 · authRouter.post("/mfa/disable")
    • L582 · authRouter.post("/mfa/verify")
    • L696 · authRouter.post("/change-password")
    • L738 · authRouter.post("/mfa/regenerate-backup-codes")
    • L793 · authRouter.post("/token")

    Environment references: c.env.ENVIRONMENT · c.env.DB · c.env.SETUP_TOKEN

    src/api/users.ts ↗
    • L41 · usersRouter.get("/")
    • L87 · usersRouter.get("/:id")
    • L111 · usersRouter.post("/")
    • L203 · usersRouter.put("/:id")
    • L318 · usersRouter.put("/:id/domains")
    • L400 · usersRouter.get("/:id/domains")
    • L423 · usersRouter.delete("/:id")

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

    src/api/tags.ts ↗
    • L26 · tagsRouter.get("/")
    • L65 · tagsRouter.get("/:id")
    • L89 · tagsRouter.post("/")
    • L119 · tagsRouter.put("/:id")
    • L156 · tagsRouter.delete("/:id")
    src/api/categories.ts ↗
    • L26 · categoriesRouter.get("/")
    • L65 · categoriesRouter.get("/:id")
    • L89 · categoriesRouter.post("/")
    • L119 · categoriesRouter.put("/:id")
    • L156 · categoriesRouter.delete("/:id")
    src/api/apiKeys.ts ↗
    • L33 · apiKeysRouter.get("/")
    • L98 · apiKeysRouter.get("/:id")
    • L128 · apiKeysRouter.post("/")
    • L189 · apiKeysRouter.put("/:id")
    • L265 · apiKeysRouter.delete("/:id")

    Environment references: c.env.DB

    src/api/settings.ts ↗
    • L41 · settingsRouter.get("/status-check-frequency")
    • L61 · settingsRouter.put("/status-check-frequency")
    • L95 · settingsRouter.get("/analytics-aggregation")
    • L112 · settingsRouter.put("/analytics-aggregation")
    • L138 · settingsRouter.get("/analytics-thresholds")
    • L155 · settingsRouter.put("/analytics-thresholds")
    • L196 · settingsRouter.get("/root-page")
    • L208 · settingsRouter.put("/root-page")
    src/api/import.ts ↗
    • L36 · importRouter.post("/")
    • L453 · parseCSV calls (conditional paths may differ): text.split, map, split, replace, h.trim, trim, values.push, result.push
    src/api/static.ts ↗
    • L20 · app.get("/base.css")
    • L26 · app.get("/dark-mode.css")
    • L32 · app.get("/components.css")
    • L38 · app.get("/utils/api-client.js")
    • L44 · app.get("/utils/toast.js")
    • L50 · app.get("/utils/pagination.js")
    src/services/cache.ts ↗
    • L23 · getDomainCacheVersion calls (conditional paths may differ): env.CACHE.get, parseInt
    • L29 · invalidateDomainCache calls (conditional paths may differ): getDomainCacheVersion, env.CACHE.put, String
    • L40 · getCachedLink calls (conditional paths may differ): env.CACHE.get
    • L50 · setCachedLink calls (conditional paths may differ): env.CACHE.put, JSON.stringify
    • L60 · deleteCachedLink calls (conditional paths may differ): env.CACHE.delete
    • L65 · getCachedDomain calls (conditional paths may differ): getDomainCacheVersion, getDomainCacheKey, env.CACHE.get
    • L72 · setCachedDomain calls (conditional paths may differ): getDomainCacheVersion, getDomainCacheKey, domains.filter, env.CACHE.put, JSON.stringify

    Environment references: env.CACHE

    src/db/links.ts ↗
    • L13 · getLinkBySlug calls (conditional paths may differ): first, bind, env.DB.prepare
    • L27 · getLinkById calls (conditional paths may differ): first, bind, env.DB.prepare
    • L33 · getLinkByIdIncludingDeleted calls (conditional paths may differ): first, bind, env.DB.prepare
    • L38 · createLink calls (conditional paths may differ): generateId, Date.now, first, bind, env.DB.prepare, run, getLinkById
    • L112 · updateLink calls (conditional paths may differ): fields.push, values.push, Date.now, run, bind, env.DB.prepare, fields.join, getLinkById
    • L163 · deleteLink calls (conditional paths may differ): run, bind, env.DB.prepare
    • L175 · listLinks calls (conditional paths may differ): join, options.domainIds.map, params.push, all, bind, env.DB.prepare
    • L239 · countLinks calls (conditional paths may differ): join, options.domainIds.map, params.push, first, bind, env.DB.prepare
    • L290 · incrementClickCount calls (conditional paths may differ): run, bind, env.DB.prepare
    • L300 · updateUniqueVisitors calls (conditional paths may differ): run, bind, env.DB.prepare
    • L310 · getFilteredLinkIds calls (conditional paths may differ): params.push, join, options.linkIds.map, options.domainIds.map, options.tagIds.map, options.categoryIds.map, all, bind, env.DB.prepare, map
    • L371 · checkSlugExists calls (conditional paths may differ): first, bind, env.DB.prepare
    • L385 · listLinksWithTagFilter calls (conditional paths may differ): join, options.domainIds.map, params.push, query.replace, first, bind, env.DB.prepare, all
    • L482 · getLinksForStatusCheck calls (conditional paths may differ): Date.now, all, bind, env.DB.prepare
    • L507 · getTopLinksForDailyCheck calls (conditional paths may differ): all, bind, env.DB.prepare
    • L524 · updateLinkStatusCheck calls (conditional paths may differ): Date.now, run, bind, env.DB.prepare
    • L547 · recordStatusCheck calls (conditional paths may differ): generateId, Date.now, run, bind, env.DB.prepare
    • L578 · getLinksByStatusCode calls (conditional paths may differ): params.push, query.replace, first, bind, env.DB.prepare, all
    • L628 · getLinksByDestinationUrl calls (conditional paths may differ): params.push, all, bind, env.DB.prepare
    • L659 · getLinksGroupedByDestination calls (conditional paths may differ): params.push, first, bind, env.DB.prepare, all, map, row.link_ids.split, console.error
    • L755 · getStatusSummary calls (conditional paths may differ): params.push, all, bind, env.DB.prepare, row.last_status_code.toString

    Environment references: env.DB

    src/db/linkRedirects.ts ↗
    • L51 · getGeoRedirects calls (conditional paths may differ): all, bind, env.DB.prepare
    • L61 · upsertGeoRedirect calls (conditional paths may differ): generateId, Date.now, run, bind, env.DB.prepare, countryCode.toUpperCase
    • L80 · deleteGeoRedirect calls (conditional paths may differ): run, bind, env.DB.prepare, countryCode.toUpperCase
    • L90 · clearAllGeoRedirects calls (conditional paths may differ): run, bind, env.DB.prepare
    • L96 · getDeviceRedirects calls (conditional paths may differ): all, bind, env.DB.prepare
    • L106 · upsertDeviceRedirect calls (conditional paths may differ): generateId, Date.now, run, bind, env.DB.prepare
    • L125 · deleteDeviceRedirect calls (conditional paths may differ): run, bind, env.DB.prepare
    • L135 · clearAllDeviceRedirects calls (conditional paths may differ): run, bind, env.DB.prepare
    • L141 · getCityRedirects calls (conditional paths may differ): all, bind, env.DB.prepare
    • L151 · upsertCityRedirect calls (conditional paths may differ): generateId, Date.now, run, bind, env.DB.prepare, cityName.toLowerCase
    • L170 · deleteCityRedirect calls (conditional paths may differ): run, bind, env.DB.prepare, cityName.toLowerCase
    • L182 · clearAllCityRedirects calls (conditional paths may differ): run, bind, env.DB.prepare
    • L188 · getOsRedirects calls (conditional paths may differ): all, bind, env.DB.prepare
    • L198 · upsertOsRedirect calls (conditional paths may differ): generateId, Date.now, run, bind, env.DB.prepare
    • L217 · deleteOsRedirect calls (conditional paths may differ): run, bind, env.DB.prepare
    • L227 · clearAllOsRedirects calls (conditional paths may differ): run, bind, env.DB.prepare
    • L232 · getLinksGeoRedirectsBatch calls (conditional paths may differ): chunks.push, linkIds.slice, Promise.all, chunks.map, join, chunkIds.map, all, bind, env.DB.prepare, results.flat, map.has, map.set, push, map.get
    • L274 · getLinksDeviceRedirectsBatch calls (conditional paths may differ): chunks.push, linkIds.slice, Promise.all, chunks.map, join, chunkIds.map, all, bind, env.DB.prepare, results.flat, map.has, map.set, push, map.get
    • L314 · getLinksCityRedirectsBatch calls (conditional paths may differ): chunks.push, linkIds.slice, Promise.all, chunks.map, join, chunkIds.map, all, bind, env.DB.prepare, results.flat, map.has, map.set, push, map.get
    • L354 · getLinksOsRedirectsBatch calls (conditional paths may differ): chunks.push, linkIds.slice, Promise.all, chunks.map, join, chunkIds.map, all, bind, env.DB.prepare, results.flat, map.has, map.set, push, map.get
    • L401 · saveLinkRedirects calls (conditional paths may differ): upsertGeoRedirect, upsertDeviceRedirect, upsertCityRedirect, upsertOsRedirect

    Environment references: env.DB

    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.

    Summarize new issues · .github/workflows/summary.yml ↗

    Triggers: issues

    summary · no job dependencies declared

    1. Checkout repositoryactions/checkout@v4
    2. Run AI inferenceactions/ai-inference@v1
    3. Comment with AI summarygh issue comment $ISSUE_NUMBER --body '${{ steps.inference.outputs.response }}'
    package.json ↗
    • deploy: npm run db:migrate && wrangler deploy
    • deploy:only: wrangler deploy
    • migrate: wrangler d1 migrations apply openshortlink-db --remote
    • migrate:local: wrangler d1 migrations apply openshortlink-db --local

    Full upstream document by @idhamsy · README.md · snapshot bd337fc

    OpenShort.link - The All-in-One Open Source Serverless URL Link Shortener. 100% on Cloudflare + 1 Click Install

    License: AGPL-3.0 Cloudflare Workers Serverless

    Easy Setup Beginner Friendly Free Tier Available Perfect for Small Business

    GitHub stars GitHub forks

    The Open-Source, Serverless Link Shortener Built for Everyone.

    OpenShort.link is an open-source link shortener deployable with a one-click install on Cloudflare, featuring full functionality and working on your existing domain with Cloudflare routing. Whether you're a blogger, marketer, e-commerce business, or brand, OpenShort.link gives you the tools to shorten links, track clicks, and route users intelligently based on location or device—all from your own custom domain.

    Deploy with a single click and manage your links across multiple domains with comprehensive analytics, team collaboration, and powerful automation.

    Deploy to Cloudflare

    ✨ Features

    🚀 Core Features

    • Fast Redirects: 100% on Cloudflare edge using Workers & KV for lightning-fast performance
    • Custom Domains: Connect your own domain for branded short links
    • Custom Slugs: Create memorable, branded short URLs
    • Multi-Domain Support: Manage links across multiple domains from one account
    • Automatic URL Checker: Monitors destination URL status to prevent dead links

    🎯 Advanced Redirects

    • Geo-Targeting: Route users to different URLs based on their country (up to 10 countries per link)
    • Device-Based Routing: Target users with precision based on device type (desktop, mobile, tablet)
    • Custom Redirect Codes: Choose from 301, 302, 307, or 308 HTTP redirect codes

    🔗 Social Sharing (Open Graph)

    • Custom Link Previews: Set per-link Open Graph / Twitter Card tags (title, description, image, type) so a shared short link shows a rich preview on Facebook, X, LinkedIn, Slack, Discord, WhatsApp, and more — while human visitors are still redirected normally
    • Fetch from Destination: One click pulls the destination page's existing OG tags to pre-fill the fields (only blank fields, so your edits are kept), SSRF-guarded

    📊 Full Analytics

    Powered by Cloudflare Analytics Engine, track everything:

    • Real-time Click Tracking: Monitor link performance as it happens
    • Geographic Data: See where your visitors are coming from
    • Device & Browser Analytics: Desktop, mobile, tablet breakdown plus OS and browser stats
    • Referrer Tracking: Identify your top traffic sources
    • UTM Campaign Tracking: Track marketing campaigns with UTM parameters
    • Custom Parameters: Monitor custom URL parameters for advanced tracking

    🏷️ Organization & Management

    • Tags & Categories: Organize links with colored tags and categories for easy management
    • Search & Filtering: Quickly find links by slug, URL, title, tags, or categories
    • Bulk Operations: Update multiple links at once
    • Import/Export Data: Seamlessly migrate your data in and out with CSV support
    • Column Mapping: Smart CSV import with automatic column detection

    📱 QR Code Generation

    • Built-in QR Codes: Generate QR codes for any link instantly
    • QR Code Tracking: Track offline engagement with dynamic QR codes
    • Downloadable: Save QR codes for print materials and marketing

    🔐 Security & Access Control

    • Multi-Factor Authentication (MFA): Secure accounts with TOTP-based 2FA
    • Role-Based Access Control: Owner, Admin, Editor, and Viewer roles
    • API Keys: Generate secure API keys for programmatic access
    • Session Management: Secure sessions with HttpOnly, Secure, SameSite cookies
    • Password Security: PBKDF2 hashing with 100,000 iterations and SHA-256
    • Rate Limiting: Protection against brute force attacks

    👥 Team Collaboration

    • Multi-User Support: Collaborate with your team using different roles
    • User Management: Admins can create and manage team members
    • Permission Levels: Granular control over who can create, edit, or view links

    🔌 Developer Features

    • RESTful API: Full API access for automation and integration
    • API Documentation: Comprehensive API docs with examples
    • TypeScript: Fully typed codebase for reliability

    🎯 Built for Every Need

    • 📝 Blog: Cloak links and track external link performance
    • 📱 Social Media: Shorten links and track click performance for posts or partnerships
    • 🛍️ E-commerce: Shorten product links and gain insights into customer engagement
    • 💰 Affiliate Marketing: Track and optimize campaigns with detailed link analytics
    • 🏢 Brand: Build brand recognition with custom short links
    • 🌐 Any Use Case: Shorten links, track clicks, and smart route by location or device

    🛠️ Tech Stack

    • Cloudflare Workers: Serverless runtime for global edge computing
    • Cloudflare D1: SQL database for reliable data storage
    • Cloudflare KV: High-speed caching for fast redirects
    • Cloudflare Analytics Engine: Real-time click tracking and analytics
    • Hono: Lightweight, fast web framework
    • TypeScript: Type-safe development

    🚀 Quick Start

    Prerequisites

    • Cloudflare account (free tier works!)
    • Node.js 18+ and npm (for local development)
    • Wrangler CLI: npm install -g wrangler

    📦 One-Click Deployment

    The easiest way to get started. This defaults to a completely automated setup.

    1. Click the Deploy Button at the top of this page or visit OpenShort.link

      Deploy to Cloudflare

    2. Follow the Automated Setup:

      • You will be guided to authorize Cloudflare Workers.
      • The system will automatically fork this repository to your GitHub account.
      • It will automatically create the required D1 database (openshortlink-db) and KV namespace (CACHE).
      • Database migrations are applied automatically during deployment.
      • Enter Secrets: You will be prompted to enter values for:
        • SETUP_TOKEN: Choose based on your security needs:
          • 🔐 Production (Most Secure): Generate with openssl rand -hex 32
          • 🛡️ Personal Use: Use a strong password (20+ chars, mixed case, numbers, special chars)
          • 🔧 Testing: Generate UUID with uuidgen or visit uuidgenerator.net
        • CLOUDFLARE_ACCOUNT_ID: Your Cloudflare Account ID.
        • CLOUDFLARE_API_TOKEN: An API Token with "Account Analytics Read" permission.
    3. Configure Worker Routes:

      • After deployment, go to the Cloudflare Dashboard → Workers & Pages → Your Worker → Settings → Triggers.
      • Add routes for your custom domain:
        • yourdomain.com/dashboard/*
        • yourdomain.com/go/*

      Note: Ensure these routes do not clash with existing paths on your website.

    4. Create Your First User:

      • Navigate to https://your-worker.workers.dev/dashboard/setup
      • Enter your SETUP_TOKEN.
      • Fill in username, email, and password.

    That's it! Your link shortener is fully deployed and ready.

    🔧 Manual Deployment

    For more control over the deployment process, including local development setup, custom configurations, and CI/CD integration, please refer to the detailed Deployment Guide.

    🔐 Security

    Application Security Features

    • No Public Registration: Only admins can create new users
    • MFA Support: Enable two-factor authentication for added security
    • API Keys: Generate secure keys for API access
    • Session Tokens: Secure, HttpOnly cookies with 7-day expiration
    • Rate Limiting: 5 login attempts per minute, 3 registrations per hour
    • Role-Based Access Control: Owner, Admin, Editor, and Viewer roles with granular permissions

    For additional security, configure Cloudflare WAF (Web Application Firewall) rules to protect your dashboard and API endpoints:

    Note: You can create separate rules for /dashboard and /api with different restrictions based on your needs. The examples below apply to both endpoints.

    IP Restriction

    Restrict access to trusted IP addresses:

    1. Go to Cloudflare Dashboard → Security → WAF → Custom Rules
    2. Create a new rule:
      • Rule name: "Dashboard/API IP Restriction"
      • If: (http.request.uri.path starts with "/dashboard" or http.request.uri.path starts with "/api") and ip.src not in {1.2.3.4 5.6.7.8}
      • Then: Block

    Geographic Restrictions

    Block access from countries outside your allowed list:

    1. Create a new rule:
      • Rule name: "Dashboard Geo-Blocking"
      • If: (http.request.uri.path starts with "/dashboard" or http.request.uri.path starts with "/api") and ip.geoip.country not in {"US" "GB" "AU"}
      • Then: Block

    Additional Rate Limiting

    Enhance rate limiting beyond application-level protection:

    1. Go to Security → WAF → Rate Limiting Rules
    2. Create a rule:
      • Rule name: "Dashboard Rate Limit"
      • If: http.request.uri.path starts with "/dashboard" or http.request.uri.path starts with "/api"
      • Requests: 100 requests per 10 minutes
      • Then: Block for 1 hour

    Note: These Cloudflare rules work at the edge before requests reach your Worker, providing an additional security layer.

    📖 API Documentation

    For detailed API documentation, including request/response examples and OpenAPI specifications, please visit your installation's dashboard and navigate to Integration -> Manual Integration.

    🐛 Reporting Issues

    Found a bug or have a feature request? We'd love to hear from you!

    Go to GitHub Issues

    Feature Requests

    Have an idea for a new feature? We welcome suggestions!

    1. Check the roadmap: See if it's already planned
    2. Open a feature request: Use the feature request template
    3. Describe the use case: Explain why this feature would be valuable
    4. Provide examples: Show how it would work

    Getting Help

    📄 License

    Licensed under the GNU Affero General Public License Version 3 (AGPL-3.0) - see LICENSE file for details.

    AGPLv3 is a strong copyleft license. If you run a modified version of this software over a network, you must make the source code available to users of that service.

    See NOTICE for third-party license information.

    🤝 Contributing

    Contributions are welcome! Please read our contributing guidelines and code of conduct.

    1. Fork the repository
    2. Create a feature branch (git checkout -b feature/amazing-feature)
    3. Commit your changes (git commit -m 'Add amazing feature')
    4. Push to the branch (git push origin feature/amazing-feature)
    5. Open a Pull Request

    🌟 Support the Project

    If you find OpenShort.link useful, please consider:

    • 💰 Sponsor our project, contact us for details
    • ⭐ Starring the repository
    • 🐛 Reporting bugs and requesting features
    • 📖 Contributing to documentation
    • 💻 Submitting pull requests
    • 📢 Sharing with others

    Built with ❤️ using Cloudflare Workers

    Frequently asked about OpenShort.link

    What is OpenShort.link?+

    OpenShort.link is a self-hosted Bitly/Rebrandly alternative built on the Cloudflare developer platform. Manage short links, QR codes and click analytics with a Cloudflare-hosted team dashboard.

    What does OpenShort.link replace?+

    OpenShort.link is listed as an alternative to Bitly, Rebrandly, Short.io. Compare the features and tradeoffs before migrating.

    What Cloudflare primitives does OpenShort.link use?+

    OpenShort.link is built on Analytics Engine, D1, KV, Workers.

    How much does OpenShort.link cost to run?+

    The documented OpenShort.link 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. Keep Analytics Engine within 100,000 data points/day and 10,000 queries/day; one event can write to multiple datasets. 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 OpenShort.link open source?+

    The upstream repository declares the AGPL-3.0 license. Read its terms at https://raw.githubusercontent.com/idhamsy/openshortlink/bd337fcf45f237087373e9a837bb884d02e7eb4f/LICENSE. Source code and contributor credit are available at https://github.com/idhamsy/openshortlink.

    Discussion · 0

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