liblin
libl.in (libre link) is a URL shortner built for hackers by a hacker. If you have a Codeberg account, you are already a libl.in user.
liblin is a free software, released under GPLV3 or later, and it’ll remain library forever. It stores know none of your data, yet it adds as your URL shortner. However in the future, for performance reasons, we could be storing your short links for a period of time.
The Idea
Most URL shorteners keep a database that maps a short code to a destination. liblin flips that around: your git repository is the database.
A liblin link has the shape:
https://libl.in/<username>/<document>
With the default libl.in mapping, <username> is a Codeberg account and
<document> is a text file on the main branch of that account’s liblin
repository. liblin fetches that file and redirects the visitor to the URL on
its first line. The rest of the file is ignored, so it can hold notes.
For example, Alice creates demo.txt in her liblin repository with this
first line:
https://example.com/a/very/long/url
Then https://libl.in/alice/demo redirects to that URL. The filename without
the .txt suffix becomes the last part of the short link. If Alice later edits
and commits demo.txt, the short URL stays the same and points to the new
destination.
How a request flows
sequenceDiagram
autonumber
actor V as Visitor
participant L as liblin
participant R as domain-slurps.edn
participant G as Git host
participant D as Destination
V->>L: GET https://libl.in/alice/demo
L->>L: domain = libl.in:80<br/>paths = ["alice", "demo"]
L->>R: look up libl.in:80
R-->>L: URL template
L->>L: render template
L->>G: GET /alice/liblin/raw/branch/main/demo.txt
G-->>L: first line = destination URL
L-->>V: 302 Found<br/>Location: destination URL
V->>D: follow redirect
D-->>V: destination page
- Receive the short URL. The browser requests
/alice/demofromlibl.in. - Find the domain mapping. liblin reads the RDL file named by
LIBLIN_DOMAINSand looks up the host and port,libl.in:80. The path segments becomepaths, here["alice", "demo"]. - Render the file location. The RDL template turns those segments into
the raw URL of a file on the git host. In the default mapping, the first
segment selects the repository owner and the remaining segments form the
file path inside the repository. So
alice/demomeansdemo.txtin Alice’sliblinrepository, andalice/docs/demomeansdocs/demo.txt. With no document segment,index-if-emptyfalls back toindex.txt. - Fetch the file. liblin downloads that text file from the git host.
- Read the first line. That line is the real destination; later lines are ignored.
- Redirect. liblin returns
302 Foundwith the destination in theLocationheader. The browser follows it.
liblin reads both the RDL file and the destination file on every request, so without a caching proxy a commit is visible on the next request. liblin itself keeps no link cache. A future version of liblin may cache links briefly for performance, but the repository would remain the source of truth. If you put Nginx in front of liblin, see Hosting for optional cache settings and their trade-offs.
Why git?
Using a repository as the link store has several advantages:
- No account or dashboard. Your existing Codeberg account and repository are all you need.
- Versioned by default. Every link change is a commit, so you can inspect, revert, and audit it.
- Editable anywhere. Use the Codeberg web editor, git, or any tool that can write a text file.
- Portable. The redirects are plain text in a repository you control; you can fork, back up, or move them.
- Stateless server. liblin can restart or be replaced without migrating a database.
Because the destination lives in your repository, a short link is only as private as that repository. On the hosted service the file must be publicly readable, since liblin fetches it without your credentials. Do not put secrets in a destination URL.
One server, many domains
RDL is what connects a hostname to a repository layout. The hosted service uses an entry like this:
{"libl.in:80"
"https://codeberg.org/{{ paths[0] }}/liblin/raw/branch/main/{{ paths | rest | join | index-if-empty }}.txt"}
Change the template to support a different git host, repository name, branch, or file layout. See Redirect Language for the template language and Hosting for running your own instance.
Ready to create a link? See Getting Started.
Getting Started
To use Libblin for now you need to be a Codeberg user. Once you have a codeberg account, fork liblin on Codeberg, then visit: https://libl.in/<username>/fs. For example: https://libl.in/liblin/fs.
You are welcome!
To have your own subdomain or URL, contact us.
Hosting
This page explains hosting liblin on your own VPS, which runs Debian / Ubuntu, and Nginx as a reverse proxy.
1. Download liblin JAR
Latest liblin releases could be found here https://codeberg.org/liblin/url_redirect/releases. Download the jar file, and let’s rename it to liblin.jar.
2. Install Java
On your server, run these commands:
sudo apt update
sudo apt install openjdk-25-jre-headless
java -version # verify
3. Upload the JAR
From your local machine:
scp liblin.jar user@your-vps-ip:/home/user/
4. Run it manually first (sanity check)
java -jar liblin.jar
Your app likely starts on port 3000 by default. You can override it:
PORT=8080 java -jar liblin.jar
You can use browser to test your app at http://your-vps-ip:3000, or http://your-vps-ip:8080 if you set a custom port.
5. Set up a systemd service (so it runs forever)
Create a service file:
sudo nano /etc/systemd/system/liblin.service
Paste this:
[Unit]
Description=My Clojure Ring App
After=network.target
[Service]
User=user
WorkingDirectory=/home/user
ExecStart=/usr/bin/java -jar /home/user/liblin.jar
Restart=always
RestartSec=5
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable liblin
sudo systemctl start liblin
sudo systemctl status liblin
6. Set up Nginx as a reverse proxy
sudo apt install -y nginx
Create a config:
sudo nano /etc/nginx/sites-available/liblin
server {
listen 80;
server_name your-domain.com; # or your VPS IP
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable it:
sudo ln -s /etc/nginx/sites-available/liblin /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
6.1 Optional: caching redirects
liblin itself does not keep a link cache, but Nginx can cache the responses it gets from liblin. This reduces load on liblin and on the git host, and makes repeat redirects faster.
First define a cache zone in the http context. On Debian / Ubuntu, create
/etc/nginx/conf.d/liblin-cache.conf:
proxy_cache_path /var/cache/nginx/libl
levels=1:2
keys_zone=libl_cache:10m
max_size=1g
inactive=2d
use_temp_path=off;
Create the cache directory and give Nginx permission to write to it:
sudo mkdir -p /var/cache/nginx/libl
sudo chown -R www-data:www-data /var/cache/nginx/libl
The zone is named libl_cache; that is the name used by proxy_cache below.
Define it only once. 10m is the shared memory for cache keys, max_size=1g
caps disk usage, and inactive=2d removes entries that have not been used for
two days. The proxy_cache_path directive must be in the http context, so do
not put it inside server or location.
Now add the cache directives to the location / block:
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache libl_cache;
proxy_cache_valid 200 302 1d; # cache pages and redirects for a day
proxy_cache_valid 404 1m; # cache "not found" only briefly
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Cache-Status $upstream_cache_status;
}
proxy_cache_valid 200 302 1dcaches successful responses and redirects for one day. The302is the important one for liblin.proxy_cache_valid 404 1mstops a typo from hitting liblin and the git host on every request, while letting a newly created link start working within a minute.proxy_cache_keykeeps separate entries for HTTP and HTTPS, different methods, different domains, and different paths or query strings.add_header X-Cache-Status $upstream_cache_statusshows whether a response came from the cache.MISSmeans Nginx fetched it from liblin,HITmeans it came from cache, andEXPIREDmeans the cached copy was refreshed. Useadd_header ... always;if you also want the header on 404 responses.
Reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Test with a real short link:
curl -s -o /dev/null -D - https://your-domain.com/alice/demo | grep -i x-cache-status
The first request should report MISS, and the second should report HIT.
Use http:// instead of https:// if you have not enabled HTTPS yet.
A cached redirect is served until its TTL expires, so a change to a
destination file, the RDL file, or the root index page may take up to 1d to
appear. Lower the 200 302 TTL if you change links often, or clear the cache
after updating:
sudo find /var/cache/nginx/libl -type f -delete
The cache is stored on disk, so redirect targets are kept on the server for the TTL. If that is not acceptable, disable caching or use a shorter TTL.
If you enable HTTPS later, verify that the location / block serving port 443
also has the cache directives. If Nginx reports unknown "libl_cache" cache zone, check that proxy_cache_path is in the http context and reload Nginx.
7. Open the firewall
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable
8. Add HTTPS with Let’s Encrypt (optional but recommended)
Visit Certbot’s website for detailed instructions for your OS: https://certbot.eff.org/
Certbot will auto-edit your Nginx config and set up auto-renewal.
9. Setting up ENV variables
liblin reads one env var LIBLIN_DOMAINS which is a path to a edn file containing domain mappings. Lets say you assign /home/user/liblin/domains.edn to it, read the section Domain Slurps for more details.
10. Domain Slurps
liblin is designed to operate with custom domain names or even sub-domains. For that you need a edn file, which maps domains to what it needs to redirect to.
10.1 Redirecting for all codeberg users
For example https://libl.in/liblin/cli will read the content of the file https://codeberg.org/liblin/liblin/raw/branch/main/cli.txt, and will redirect to what ever is present in it.
In https://libl.in/liblin/cli, the liblin is the codeberg user name, and cli is the file name. If there is a repository called liblin by user liblin in codeberg, then the URL will redirect to URL present in https://codeberg.org/liblin/liblin/raw/branch/main/cli.txt.
To achieve it, you need to add the following to your edn file:
{
"libl.in:80"
"https://codeberg.org/{{ paths[0] }}/liblin/raw/branch/main/{{ paths[1] | index-if-empty }}.txt"
}
Where paths is can be considered as vector containing parts of URL after the domain name. In libl.in/liblin/cli, paths will be ["liblin", "cli"] which means paths[0] is liblin and paths[1] is cli. We will be writing about {{ }} in RDL (Redirect Language).
10.2 Redirecting for your domain
Let’s say there is username, or organization in codeberg named yu7 and it has a repository named liblin, and you want to point https://yu7.in/:short to read from the file in https://codeberg.org/yu7/liblin and redirect it to whaterver is present in it, you can configure it as follows.
{
"yu7.in:80"
"https://codeberg.org/yu7/liblin/raw/branch/main/{{ paths[0] | index-if-empty }}.txt"
}
We will soon be writing albout RDL (Redirect Language).
11. Deploying new versions
When you deploy a new version, just scp the new jar over and sudo systemctl restart liblin.
12. Useful commands after deployment
sudo systemctl restart liblin # restart after uploading new jar
sudo journalctl -u liblin -f # tail logs
sudo systemctl status liblin # check status
Redirect Language (RDL)
RDL (Redirect Language) is the configuration format used by liblin. It maps an incoming host and path to the location of a text file. The first line of that text file is the URL the visitor is redirected to.
An RDL file (also called a domain-slurps file) is a single EDN map:
- each key is the host and port of an incoming request
(
"example.com:80"); - each value is a Liquid-style URL template rendered with the request’s domain and path segments.
The file is read on every request, so entries can be changed without restarting the server. For deployment and environment setup, see Hosting.
Quick example
{"example.com:80"
"https://example.com/links/{{ paths[0] | index-if-empty }}.txt"}
A request for http://example.com:80/hello is resolved as follows:
| Value | Result |
|---|---|
| Matched key | example.com:80 |
paths | ["hello"] |
| Rendered location | https://example.com/links/hello.txt |
liblin fetches that location. If the file begins with:
https://example.com/welcome
the response is 302 Found with Location: https://example.com/welcome.
Only the first line of the file is used.
Configuration file
The RDL file is read from the path in the LIBLIN_DOMAINS environment
variable:
export LIBLIN_DOMAINS=/etc/redirect/domain-slurps.edn
domain-slurps.example.edn in the liblin source repository is a
starting-point template. An RDL file must:
- contain exactly one EDN map;
- use string keys of the form
host:port; - use string values containing the URL template;
- have unique keys.
In EDN, ; starts a comment and commas are optional whitespace.
LIBLIN_DOMAINS is required; if it is unset, liblin cannot read any RDL
entries.
How a request is matched
For an incoming URL, liblin derives the lookup key as follows:
- Remove the scheme (
http://orhttps://). - Remove the path, query string, and fragment.
- Remove a leading lowercase
www.. - Keep the port.
The resulting key is looked up with an exact, case-sensitive string match.
| Incoming URL | Lookup key |
|---|---|
http://example.com:80/alice | example.com:80 |
http://www.example.com:80/alice | example.com:80 |
https://sub.example.com:443/alice | sub.example.com:443 |
http://example.com:3000/alice | example.com:3000 |
Rules to keep in mind:
- The port is always part of the key. The request handler builds the URL
with the server port, so a default HTTP request looks like
http://example.com:80/...; useexample.com:80, notexample.com. HTTPS requests similarly use:443. - Do not put
www.in keys. It is stripped before lookup, so a key such aswww.example.com:80can never match. - Matching is exact. There are no wildcards and no fallback entry. Each subdomain needs its own key.
- The scheme is not part of the key. HTTP and HTTPS are distinguished by
their ports, not by an
http/httpsprefix. - Use the port the application sees. Behind a reverse proxy this may be an internal port unless forwarded headers are configured accordingly.
Path segments
The request path is split on /. The leading empty segment and a trailing
slash are discarded, and the result is stored in paths:
| Request path | paths |
|---|---|
/alice | ["alice"] |
/alice/software/libre | ["alice" "software" "libre"] |
/alice/ | ["alice"] |
/ | [] |
Query strings and fragments are removed before splitting:
/alice?ref=mail#top gives paths equal to ["alice"]. Repeated slashes
produce empty segments in paths; the join filter skips them.
paths is a vector. Segments are zero-based and can be read with bracket
indexing or collection lookups:
| Expression | Result for ["alice" "software" "libre"] |
|---|---|
{{ paths[0] }} | alice |
{{ paths[1] }} | software |
{{ paths.first }} | alice |
{{ paths.last }} | libre |
{{ paths.size }} | 3 |
A segment that does not exist renders as an empty string. Combine it with
index-if-empty when a fallback is needed.
In liblin, / is served by the landing page and /about is a fixed route, so
neither reaches RDL. The empty paths case is still useful when calling the
redirect functions directly or in tests.
Template variables
| Variable | Description | Example |
|---|---|---|
domain | The matched key: host and port | example.com:80 |
paths | The vector of path segments | ["alice" "software" "libre"] |
Filters
Templates use Liquid syntax: {{ ... }} outputs a value and | applies a
filter. liblin provides four filters:
| Filter | Behavior |
|---|---|
index-if-empty | Returns "index" when the input is nil, "", or an empty collection; otherwise returns the input unchanged. |
first | Returns the first element of a collection. |
rest | Returns all elements after the first. Pipe it into join before outputting it. |
join | Joins collection elements with /, skipping empty elements. |
With paths equal to ["alice" "software" "libre"]:
{{ paths | first }}producesalice{{ paths | rest | join }}producessoftware/libre{{ paths | join }}producesalice/software/libre{{ paths[3] | index-if-empty }}producesindex
index-if-empty is most often used to fall back to index.txt when a segment
is missing:
"https://example.com/{{ paths[0] }}/repo/raw/branch/main/{{ paths[1] | index-if-empty }}.txt"
A request for /alice renders .../main/index.txt, while /alice/0 renders
.../main/0.txt.
Notes:
joinis liblin’s own filter, not Liquid’sjoin. It always uses/and takes no separator argument;{{ paths | join: "," }}is an error.first,rest, andjoinoperate on collections.restreturns a sequence, so do not output it directly:{{ paths | rest }}renders a Clojure-style list.- Other Liquid filters from the underlying engine (for example
append,upcase,default, andsize) remain available. liblin’s filters take precedence when names collide.
Rendering and redirecting
Rendering a template produces the location of a text file, not the final redirect target. The full flow is:
- Derive the lookup key from the request.
- Find the matching template.
- Render it with
domainandpaths. - Read the rendered location with
slurp. It can be an HTTP(S) URL or a local file path. - Take the first line, trim it, and use it as the redirect target.
- Send
302 Foundwith that target in theLocationheader.
Only the first line is used; later lines are ignored, so a target file can contain notes below the URL.
For example, if the rendered location is
https://example.com/alice/repo/raw/branch/main/software/libre.txt and that
file contains:
https://example.com/docs/libre
the client receives:
HTTP/1.1 302 Found
Location: https://example.com/docs/libre
Both the RDL file and the rendered target are read on every request; nothing is cached.
Failure behavior:
- No entry for the key:
404. - The rendered location cannot be read (missing file, network error, invalid
URL):
404. - The target file is empty: the redirect target is empty; keep at least one non-empty line in every target file.
- Malformed EDN, duplicate keys, or an invalid template: an exception
(normally HTTP
500), not a404.
Complete example
{
;; Username first; the remaining segments form the document path.
"example.com:80"
"https://example.com/{{ paths[0] }}/repo/raw/branch/main/{{ paths | rest | join | index-if-empty }}.txt"
;; Every path segment is part of the document path.
"sub.example.com:80"
"https://example.com/repo/raw/branch/main/{{ paths | join | index-if-empty }}.txt"
;; One flat document per first segment.
"api.example.com:80"
"https://example.com/raw/branch/main/{{ paths[0] | index-if-empty }}.txt"
;; Only the first two segments are used; extra segments are ignored.
"example.com:3000"
"https://example.com/{{ paths[0] }}/repo/raw/branch/main/{{ paths[1] | index-if-empty }}.txt"
;; Same request shape, different branch layout.
"alt.example.com:3000"
"https://example.com/{{ paths[0] }}/repo/refs/heads/main/{{ paths[1] | index-if-empty }}.txt"
;; One flat document per first segment, on a subdomain.
"alt.sub.example.com:3000"
"https://example.com/repo/raw/branch/main/{{ paths[0] | index-if-empty }}.txt"
;; `domain` renders as host:port, so it can be reused in the target.
"meta.example.com:80"
"http://{{ domain }}/repo/raw/branch/main/{{ paths | join | index-if-empty }}.txt"
}
The entries above produce these results:
| Request | paths | Rendered location |
|---|---|---|
http://example.com:80/alice | ["alice"] | https://example.com/alice/repo/raw/branch/main/index.txt |
http://example.com:80/alice/software/libre | ["alice" "software" "libre"] | https://example.com/alice/repo/raw/branch/main/software/libre.txt |
http://sub.example.com:80/software/libre | ["software" "libre"] | https://example.com/repo/raw/branch/main/software/libre.txt |
http://api.example.com:80/fs/ | ["fs"] | https://example.com/raw/branch/main/fs.txt |
http://example.com:3000/alice | ["alice"] | https://example.com/alice/repo/raw/branch/main/index.txt |
http://example.com:3000/alice/0 | ["alice" "0"] | https://example.com/alice/repo/raw/branch/main/0.txt |
http://alt.example.com:3000/alice/0 | ["alice" "0"] | https://example.com/alice/repo/refs/heads/main/0.txt |
http://alt.sub.example.com:3000/fs | ["fs"] | https://example.com/repo/raw/branch/main/fs.txt |
http://meta.example.com:80/a/b | ["a" "b"] | http://meta.example.com:80/repo/raw/branch/main/a/b.txt |
http://www.example.com:80/alice | ["alice"] | https://example.com/alice/repo/raw/branch/main/index.txt |
http://unknown.example.com:80/alice | ["alice"] | No entry: 404 |
Troubleshooting
- A valid-looking host returns
404. Check the port first::80,:443, and:3000are different keys. Also check that the key does not start withwww.and that the exact subdomain has an entry. - The wrong file is selected.
paths[0]is the first segment after the leading slash; the slash itself is not part ofpaths. Trailing slashes and query strings do not create segments. - Subfolders are dropped. A template that only uses
paths[0]orpaths[1]ignores later segments. Use{{ paths | rest | join }}or{{ paths | join }}to include them. - A missing segment becomes
indexwhen it should be empty. That is whatindex-if-emptydoes; remove the filter if an empty value is wanted. - Changes do not appear. Confirm
LIBLIN_DOMAINSpoints at the file the server is reading. There is no cache, so the next request should use the new value. - The server returns
500. Check for invalid EDN, duplicate keys, a non-string template, or an invalid Liquid expression such as{{ paths | join: "," }}. - Only the first line of the target file matters. A blank first line produces an empty redirect target.
RDL is trusted configuration: templates can read local files as well as remote URLs, so only trusted operators should be able to edit the RDL file.
See also
- Hosting - deploying liblin and setting
LIBLIN_DOMAINS. - Getting Started - using liblin as a user.
Client
liblin has a cli client, which can be found here.
Developer Guide
The code for liblin is here https://codeberg.org/liblin/url_redirect
Contact
- Email: hello@numerical.works
- Phone: +91 8428050777
Donate
A donation might help liblin get better. If you feel so, donate to https://paypal.me/mindaslab.