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_TOKENenvironment variable.Create a
bitly.pyfile 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_TOKENenvironment variable.Add your Cloudflare account ID to a
CLOUDFLARE_ACCOUNT_IDenvironment variable.Create a
cf_bulk_redirects.pyfile 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.