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
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.
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.
Creating/resolving short links, custom slugs, QR codes and click reporting; all vendor integrations, enterprise administration and managed SLAs are excluded.
See supporting source ↗Creating/resolving short links, custom slugs, QR codes and click reporting; all vendor integrations, enterprise administration and managed SLAs are excluded.
See supporting source ↗Creating/resolving short links, custom slugs, QR codes and click reporting; all vendor integrations, enterprise administration and managed SLAs are excluded.
See supporting source ↗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 ↗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
Cloudflare Workers · compatibility 2024-01-01
openshortlink · default
Entrypoint: src/index.ts
Cron triggers (UTC): 0 0 * * * · 0 */6 * * *
DB→ D1CACHE→ KVANALYTICS→ 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.
- 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
- L14 · errorHandler calls (conditional paths may differ): console.error, c.json, error.issues.map, e.path.join
- L13 · loggerMiddleware calls (conditional paths may differ): Date.now, next
Environment references: c.env.LOG_LEVEL
- 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
- 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
- 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
- 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
- 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
- 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
- L33 · apiKeysRouter.get("/")
- L98 · apiKeysRouter.get("/:id")
- L128 · apiKeysRouter.post("/")
- L189 · apiKeysRouter.put("/:id")
- L265 · apiKeysRouter.delete("/:id")
Environment references: c.env.DB
- 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")
- 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
- 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
- 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.
Triggers: issues
summary · no job dependencies declared
- Checkout repository
actions/checkout@v4 - Run AI inference
actions/ai-inference@v1 - Comment with AI summary
gh issue comment $ISSUE_NUMBER --body '${{ steps.inference.outputs.response }}'
deploy: npm run db:migrate && wrangler deploydeploy:only: wrangler deploymigrate: wrangler d1 migrations apply openshortlink-db --remotemigrate:local: wrangler d1 migrations apply openshortlink-db --local
Repository README
View original on GitHub ↗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
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.
✨ 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.
Click the Deploy Button at the top of this page or visit OpenShort.link
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
uuidgenor visit uuidgenerator.net
- 🔐 Production (Most Secure): Generate with
CLOUDFLARE_ACCOUNT_ID: Your Cloudflare Account ID.CLOUDFLARE_API_TOKEN: An API Token with "Account Analytics Read" permission.
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.
Create Your First User:
- Navigate to
https://your-worker.workers.dev/dashboard/setup - Enter your
SETUP_TOKEN. - Fill in username, email, and password.
- Navigate to
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
Cloudflare WAF Rules (Recommended)
For additional security, configure Cloudflare WAF (Web Application Firewall) rules to protect your dashboard and API endpoints:
Note: You can create separate rules for
/dashboardand/apiwith different restrictions based on your needs. The examples below apply to both endpoints.
IP Restriction
Restrict access to trusted IP addresses:
- Go to Cloudflare Dashboard → Security → WAF → Custom Rules
- 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:
- 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:
- Go to Security → WAF → Rate Limiting Rules
- 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!
- Check the roadmap: See if it's already planned
- Open a feature request: Use the feature request template
- Describe the use case: Explain why this feature would be valuable
- Provide examples: Show how it would work
Getting Help
- Documentation: Visit OpenShort.link/docs for detailed guides
- Discussions: Use GitHub Discussions for questions
📄 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.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - 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
🔗 Links
- Website: https://openshort.link
- Documentation: OpenShort.link/docs
- GitHub: Repository
- Issues: Report a Bug
- Discussions: Community Forum
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 →