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:

  1. Create Bit.ly API access token and add it to a BITLY_ACCESS_TOKEN environment variable.

  2. 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)
    
  3. 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:

  1. Create a Cloudflare API token with at least these permissions: Account > Account Rulesets > Edit, Account > Account Filter Lists > Edit. Add the token to a CLOUDFLARE_ACCESS_TOKEN environment variable.

  2. Add your Cloudflare account ID to a CLOUDFLARE_ACCOUNT_ID environment variable.

  3. 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()
    
  4. 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.