How We Replaced Bit.ly with Our Own URL Shortener Using Hugo and Cloudflare Pages
We have been using Bit.ly to create easily shareable short URLs, but we recently decided to replace it with our own solution. We created a Hugo-based static website where we could create the short URLs, handle redirects to their respective long URLs, and have a page for each short URL displaying its QR code.
Creating the Hugo Website
We created a basic Hugo website as outlined in the Hugo docs,
using the Congo theme. We then edited the archetypes/default.md
file to this:
+++
date = '{{ .Date }}'
draft = false
title = '{{ .File.ContentBaseName }}'
tags = []
[ params ]
redirect_url = 'https://example.com/'
+++
We could now create new short URLs by running this command (replacing my-url
with the appropriate slug):
hugo new content/posts/2025/my-url.md
We then edited the layouts/single.html
file to show the long URL and a QR code by replacing the content section with this:
{{- $text := printf "%s%s" $.Site.BaseURL ($.Slug | default
$.File.ContentBaseName) -}} {{- $opts := dict "level" "high" "scale" 8 }}
<h2>Visit <a href="{{ $text }}">{{ .Params.redirect_url }}</a></h2>
{{- with images.QR $text $opts -}}
<a href="{{ $text }}">
<img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" />
</a>
{{- end }} {{ .Content | emojify }}
Migrating Existing Short URLs
We had created thousands of short URLs on Bit.ly over the years with a cakt.us
domain.
We wanted to migrate all of them so that the URLs would still work as before.
To do this, we wrote a quick Python script that would use the Bit.ly API to download all the links, then create posts for them in our new static site:
Create Bit.ly API access token and add it to a
BITLY_ACCESS_TOKEN
environment variable.Create a
bitly.py
file in the Hugo website’s directory with these contents:import os import requests from pathlib import Path from textwrap import dedent ACCESS_TOKEN = os.getenv("BITLY_ACCESS_TOKEN") # This mapping will be used to add tags to posts based on the long URLs NEW_TAGS = { "github": "github.com/", "blog": "caktusgroup.com/blog/", "talk": "talks.caktusgroup.com/", "learn": "learn.caktusgroup.com/", "djangocon": "djangocon", "pycon": "pycon", } def request(url, params=None): """Make a request to the Bit.ly API and return the JSON result.""" response = requests.get( f"https://api-ssl.bitly.com/v4{url}", params, headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}, ) return response.json() def get_groups(): """Get all Bit.ly groups.""" return request("/groups") def get_links(): """Get all Bit.ly links.""" links = [] for group in get_groups()["groups"]: params = {"size": 100, "archived": "both"} while True: result = request(f"/groups/{group['guid']}/bitlinks", params) links += result["links"] print(f"Got {len(result['links'])} links, current total is {len(links)}") search_after = result["pagination"].get("search_after") if not search_after: break params["search_after"] = search_after return links def create_post(link, url=None): """Create a post in our Hugo site.""" if url is None: url = link["link"] if "//cakt.us/" not in url: # Skip short URLs not on our custom domain print(f"Skipping {url}") return title = url.split("/")[-1] year = link["created_at"].split("-", 1)[0] path = Path(f"content/posts/{year}/{title}.md") path.parent.mkdir(parents=True, exist_ok=True) tags = [i.lower() for i in link["tags"]] long_url = link["long_url"].lower() for key, value in NEW_TAGS.items(): if value in long_url and key not in tags: tags.append(key) break content = f"""\ +++ date = '{link["created_at"]}' draft = {(link["archived"] or "//docs.google.com/" in link["long_url"]) and "true" or "false"} title = '{title}' tags = {tags} [ params ] redirect_url = '{link["long_url"]}' +++ """ path.write_text(dedent(content)) print(f"Created {path}, for {link['long_url']}") def create_posts(links): """Create posts in our Hugo site.""" for link in links: create_post(link) for url in link["custom_bitlinks"]: create_post(link, url) if __name__ == "__main__": links = get_links() create_posts(links)
Run
python bitly.py
.
This created new files in the content/posts
folder.
Creating a _redirects
File
At this point, we had created the new pages in our Hugo site (like https://cakt.us/posts/2025/aws-web-stacks/)
but we still needed to add a way to redirect the short URLs to the long URLs
(like https://cakt.us/aws-web-stacks -> https://github.com/caktus/aws-web-stacks).
Since we were using Cloudflare Pages to host the production site, we could use
a _redirects
file to create the redirects.
We updated the layouts/home.redir
file to this:
{{- /* Generate _redirects file from posts with redirect_url param and non-empty slug */ -}}
{{- range where .Site.RegularPages "Section" "posts" -}}
{{- $slug := .Slug | default .File.ContentBaseName -}}
{{- if and $slug (.Params.redirect_url) -}}
/{{ $slug }} {{ .Params.redirect_url }} 302
{{ end -}}
{{- end -}}
This tells Hugo to create the public/_redirects
file, with one line for each post.
The lines look like this:
/aws-web-stacks https://github.com/caktus/aws-web-stacks 302
This seemed to work great. However, we learned that Cloudflare Pages imposes a limit
of 2,100 redirects in the _redirects
file.
After migrating the Bit.ly links, our _redirects
file had 5,800 redirects. All the redirects we spot-checked seemed
to work fine, so the limit may not be a hard limit, but we decided to look for an
alternative solution just in case Cloudflare decided to enforce the limit.
Bulk-Creating Redirects using the Cloudflare API
Cloudflare provides an API for bulk-creating redirects.
We decided to use this to create redirects for all the links we migrated from Bit.ly,
then use the _redirects
file only for new short URLs.
To create the redirects using the API, we wrote another quick Python script:
Create a Cloudflare API token with at least these permissions:
Account > Account Rulesets > Edit
,Account > Account Filter Lists > Edit
. Add the token to aCLOUDFLARE_ACCESS_TOKEN
environment variable.Add your Cloudflare account ID to a
CLOUDFLARE_ACCOUNT_ID
environment variable.Create a
cf_bulk_redirects.py
file in the Hugo website’s directory with these contents:import os import requests import bitly CLOUDFLARE_ACCESS_TOKEN = os.getenv("CLOUDFLARE_ACCESS_TOKEN") CLOUDFLARE_ACCOUNT_ID = os.getenv("CLOUDFLARE_ACCOUNT_ID") # Remember to replace "cakt.us" with your own domain name below! DOMAIN = "cakt.us" LIST_NAME = f"{DOMAIN.replace('.', '_').replace('-', '_')}_redirects" def request(method, url, data=None): """Make a request to the Cloudflare API and return the JSON result.""" response = requests.request( method, f"https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}{url}", json=data, headers={"Authorization": f"Bearer {CLOUDFLARE_ACCESS_TOKEN}"}, ) return response.json() def create_list(): """Create a Bulk Redirect List.""" print(f"Creating Bulk Redirect List '{LIST_NAME}'") response = request("post", "/rules/lists", {"name": LIST_NAME, "kind": "redirect"}) print(response) return response def create_redirect(bitly_link, source_url=None, exclude_set=set()): """Create a dict for adding a single redirect to a Bulk Redirect List.""" if source_url is None: source_url = bitly_link["link"] if "//cakt.us/" not in source_url: print(f"Skipping {source_url}") return source_url = source_url.split("//")[1] if DOMAIN != "cakt.us": source_url = f"{DOMAIN}/{source_url.split('/', 1)[1]}" if source_url in exclude_set: return exclude_set.add(source_url) target_url = bitly_link["long_url"] print(f"Creating redirect: {source_url=}, {target_url=}") return { "redirect": { "source_url": source_url, "target_url": target_url, "status_code": 302, } } def create_redirects(bitly_links): """Create dicts for adding multiple redirects to a Bulk Redirect List.""" redirects = [] for link in bitly_links: redirect = create_redirect(link) if redirect: redirects.append(redirect) for url in link["custom_bitlinks"]: redirect = create_redirect(link, url) if redirect: redirects.append(redirect) return redirects def create_list_items(list_id, bitly_links): """Add redirects to a Bulk Redirect List based on Bit.ly links. bitly_links should be the output of bitly.get_links(). """ redirects = create_redirects(bitly_links) print(f"Creating {len(redirects)} redirects") response = request("post", f"/rules/lists/{list_id}/items", redirects) print(response) return redirects, response def get_op_status(op_id): """Get the status of a bulk operation. This can be used to check the status of the bulk operation done by create_list_items(), which will have an operation_id in its result. """ return request("get", f"/rules/lists/bulk_operations/{op_id}") def list_rulesets(): """Lists all rulesets.""" return request("get", "/rulesets") def create_ruleset(): """Create a Bulk Redirect Rule to enable the redirects in the Bulk Redirect List.""" data = { "name": "Redirects ruleset", "kind": "root", "phase": "http_request_redirect", "rules": [ { "ref": f"enable_{LIST_NAME}", "expression": f"http.request.full_uri in ${LIST_NAME}", "description": f"{DOMAIN} bulk redirects rule", "action": "redirect", "action_parameters": { "from_list": {"name": LIST_NAME, "key": "http.request.full_uri"} }, } ], } return request("post", "/rulesets", data) if __name__ == "__main__": cf_list = create_list() print("Getting Bit.ly links...") links = bitly.get_links() create_list_items(cf_list["result"]["id"], links) create_ruleset()
Run
python cf_bulk_redirects.py
.
We then updated the layouts/home.redir
file to only add new redirects to the _redirects
file:
{{- /* Generate _redirects file from posts with redirect_url param and non-empty slug */ -}}
{{- $posts := where .Site.RegularPages "Section" "posts" -}}
{{- /* Only posts created since 2025-09-01. Redirects for earlier posts (imported from bit.ly) are bulk-created using the Cloudflare API */ -}}
{{- $posts := where $posts ".Date.Unix" "gt" 1756674000 -}}
{{- range $posts -}}
{{- $slug := .Slug | default .File.ContentBaseName -}}
{{- if and $slug (.Params.redirect_url) -}}
/{{ $slug }} {{ .Params.redirect_url }} 302
{{ end -}}
{{- end -}}
That’s it! We could now create short URLs on our cakt.us
domain name simply by adding
a new post in our Hugo website, and we had migrated our old short URLs from Bit.ly.