Custom Shortlinks with Shlink

I setup Shlink in a Digital Ocean droplet, and put it behind Cloudflare. There's nothing super unique here, but I wanted to document it both for my sake, and anyone else that has a similar setup.

I'm using the smallest/cheapest Digital Ocean droplet I can. As usual, I don't expect this to be serving a ton of traffic, and Cloudflare will help offload some of that. In this case, that means a 10GB regular SSD, 1 CPU, and 512MB of ram running Ubuntu 24.04.

A lot of this was back and forth. Start the docker, make some changes to MariaDB, restart things. I'm not going to try to go in order so if you find something isn't working, check if there is a dependency that might be addressed later. In general though, I'm going to try to keep to a top-down order that should work.

DNS/Cloudflare

You can register a new short domain, I'm using a subdomain on mine. So I setup a proxied A record for "link" (so my short links will be https://link.nullsec.us) and pointed it to the Digital Ocean IP address.

One thing you may want to do is bypass caching for the REST API. You can do this under Caching -> Cache Rules and add a custom filter expression. Use URI Path -> starts with -> /rest/. Expression should look like (starts_with(http.request.uri.path, "/rest/")). Then select "Bypass cache" and save. This way if you want to use https://app.shlink.io to manage things, it shouldn't pull old/cached results.

NGINX

Start with sudo apt install nginx. There's a few things we'll be modifying, as we go along, periodically run nginx -t to make sure the configuration is valid and systemctl reload nginx to make sure everything is working.

nginx.conf

Located in /etc/nginx/nginx.conf. I changed two things in this file.

The first is the addition of a custom logging format to add $scheme (e.g. HTTP or HTTPS) and $host (e.g. link.nullsec.us or the IP of the server). You should already have an access_log line, just add the custom_combined to the end of it.

The second is the include line to get the real IP address from Cloudflare. This should then be passed along to our Shlink instance.

http {

        [...SNIP...]

        ##
        # Logging Settings
        ##
        
        log_format custom_combined '$remote_addr - $remote_user [$time_local] '
                           '"$request" $status $body_bytes_sent '
                           '"$http_referer" "$http_user_agent" '
                           '"$scheme" "$host"';

        access_log /var/log/nginx/access.log custom_combined;

        [...SNIP...]

        # Get Cloudflare RealIP
        include /etc/nginx/cloudflare_ips.conf;
}

Cloudflare Origin IP's

There's a few things we need for this. First, a small script to pull and deploy the list of Cloudflare IP's. I placed the following script from ChatGPT in /usr/local/bin/update-cloudflare-ips.sh

#!/bin/bash

# Define the URLs for Cloudflare IPs
CLOUDFLARE_IPV4_URL="https://www.cloudflare.com/ips-v4"
CLOUDFLARE_IPV6_URL="https://www.cloudflare.com/ips-v6"

# Define the Nginx configuration file for Cloudflare IPs
NGINX_CONF="/etc/nginx/cloudflare_ips.conf"

# Fetch the IPs and format them for Nginx
{
    echo "# Cloudflare IPs - Updated: $(date)"
    curl -s $CLOUDFLARE_IPV4_URL | awk '{print "set_real_ip_from " $0 ";"}'
    curl -s $CLOUDFLARE_IPV6_URL | awk '{print "set_real_ip_from " $0 ";"}'
} > $NGINX_CONF

# Add the real_ip_header directive if not already in your main config
if ! grep -q "real_ip_header CF-Connecting-IP;" $NGINX_CONF; then
    echo "real_ip_header CF-Connecting-IP;" >> $NGINX_CONF
fi

# Test Nginx configuration
nginx -t
if [ $? -eq 0 ]; then
    # Reload Nginx if the configuration test passes
    systemctl reload nginx
    echo "Nginx reloaded successfully with updated Cloudflare IPs."
else
    echo "Error: Nginx configuration test failed. Check the syntax."
fi

Then make it executable with chmod +x and open crontab editor with crontab -e. You can set it for any time you want, I used 0 3 * * * /usr/local/bin/update-cloudflare-ips.sh > /var/log/update-cloudflare-ips.log 2>&1

If you run the script for the first time manually (sudo /usr/local/bin/update-cloudflare-ips.sh), it should pull down the list of IP and place them in /etc/nginx/cloudflare_ips.conf. You can check to makeHTTPsure the configuration is valid with nginx -t.

shlink.conf

Lastly, lets finish the configuration specific to our site in /etc/nginx/sites-available/shlink.conf Here's the whole thing and a breakdown by server block. If you are following along at home I have replaced my site with link.site.com as a placeholder.

First, any request that comes in via IP only, e.g. if your servers IP was 256.83.387.44 (purposefully not valid) and someone requested https://256.83.387.44/, it would be served by default. None of that will ever be valid to me, so I'm returning a 400 error.

Second, redirect anything coming in on port 80 to 443 (HTTP to HTTPS redirect). You can see Shlink's basic configuration here: https://shlink.io/documentation/advanced/exposing-through-reverse-proxy/ My order is a little different, and the SSL Ciphers were taken from Mozilla SSL Configuration Generator. Everything necessary is there though.

# Block requests to the server's IP address
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 default_server;
    listen [::]:443 default_server;

    # Return 400 for requests to the IP address
    return 400;

    # SSL configuration for HTTPS connections to the IP
    ssl_certificate /etc/letsencrypt/live/link.site.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/link.site.com/privkey.pem;
}

server {
    listen 80;
    server_name link.site.com;

    # Redirect HTTP to HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name link.site.com;

    # SSL configuration
    ssl_certificate /etc/letsencrypt/live/link.site.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/link.site.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;

    # Proxy settings
    location / {
        proxy_pass http://localhost:8080;  # Shlink container's port
        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_set_header X-Forwarded-Port 443;
    }
}

Now symlink your sites-available to sites-enabled so it's... enabled. sudo ln -s /etc/nginx/sites-available/shlink.conf /etc/nginx/sites-enabled/.

certbot

We can use certbot to get a Lets Encrypt certificate. Again, replace the link.site.com placeholder with your own site. You should have no issues getting a certificate. --dry-run should work, and if so it means that the next renewal shouldn't have any issues. Lastly, look at systemctl timers for certbot, this should be the indication that auto-renew is in place.

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d link.site.com
sudo certbot renew --dry-run
sudo systemctl list-timers

MariaDB (MySQL)

Shlink will, by default, use a SQLite database inside the docker container. This means any time it's upgraded, you'll lose everything. To prevent this, we're going to point it to an external (to the docker) database that will be preserved through upgrades.

sudo apt install mariadb-server will get us started, followed by mysql_secure_installation. The secure installation will walk you through setting a root password (good idea) and some secure defaults.

Once in place, use sudo mysql -u root -p to enter MySQL and we are going to create a user and database for Shlink. Change the password for the shlinkuser -_-

CREATE DATABASE shlink;
CREATE USER 'shlinkuser'@'localhost' IDENTIFIED BY 'your_password_here';
GRANT ALL PRIVILEGES ON shlink.* TO 'shlinkuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Docker Install/Setup

Straight from the Docker docs: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository

# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update

After we have the repositories setup, install the latest version: sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

You should now be able to run the hello-world container if you want sudo docker run hello-world

You can use docker images to see the hello-world image that you pulled down and docker ps -a will show all containers (including stopped). If you want to clean up the hello-world, we need to remove the stopped container via the ID from ps -a, which in my case looks like docker rm 1dc6c1d43c95 and then remove the image with docker rmi hello-world.

Start Shlink container.

You should now be able to pull down and start Shlink. You may want to start with the basic method listed here: https://shlink.io/documentation/install-docker-image/, but I've put my final configuration below.

docker run \
    --name my_shlink \
    -p 8080:8080 \
    -e DEFAULT_DOMAIN=link.site.com \
    -e IS_HTTPS_ENABLED=true \
    -e GEOLITE_LICENSE_KEY=License_Key_Here \
    -e DB_DRIVER=maria \
    -e DB_NAME=shlink \
    -e DB_USER=shlinkuser \
    -e DB_PASSWORD=your_password_here! \
    -e DB_HOST=127.0.0.1 \
    -e DB_PORT=3306 \
    --network="host" \
    --restart="always" \
    -d \
    shlinkio/shlink:stable

You will need your Geolite License Key, which is free. We haven't gone over that, but you can find the instructions here. You'll also need your database password from above. As before, replace the site placeholder.

There are three other non-default options at the bottom:

--network="host" - This allows the container to see network services running on the host (MySQL on 3306 in this case). There are other ways to do this, I believe, but since this is a single service VM, it seemed like the easiest.

--restart="always" - Restarts the container should it be stopped or go down for some reason. Specifically, make sure it restarts after a reboot.

-d - Detach, e.g. run the container in the background so after it starts you get dropped back to command prompt.

If you have issues, docker logs my_shlink will show you the logs and hopefully any related errors.

Useful Commands

One of the first things you probably want to do is create an API key (and store it somewhere secure). This can be found in the second line below. You can use this API key along with https://app.shlink.io to more easily manage your links.

# List Commands
docker exec -it my_shlink /usr/local/bin/shlink list

# Generate API Key
docker exec -it my_shlink /usr/local/bin/shlink api-key:generate

# Access Container
docker exec -it my_shlink /bin/sh

# Periodically run to update Maxmind DB
docker exec -it my_shlink /usr/local/bin/shlink visit:download-db

# Periodically run to perform geolocate on IPs
docker exec -it my_shlink /usr/local/bin/shlink visit:locate

# List all short URL's
docker exec -it my_shlink /usr/local/bin/shlink short-url:list

Wrapping up

A few more hygiene issues. Make sure everything is set to start on boot

sudo systemctl enable nginx
sudo systemctl enable mysql

Enable Ubuntu's firewall (UFW). Tweak as needed, I'm sure you want HTTP and HTTPS. I put more granular controls on SSH via Digital Ocean firewalls. This at least makes sure MySQL doesn't get exposed.

sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo ufw enable
sudo ufw status verbose

You should consider turning up any other logging as desired, which is outside the scope of this post. Some things to consider are increasing the UFW logging level, installing Sysmon for Linux, or setting bash logging to log commands immediately. Log forwarding is always desired as well.

Ubuntu unattended upgrades should keep the system patched on a regular basis. Again, outside the scope, but make sure you are applying updates one way or another. Speaking of which...

Upgrading Shlink

In the future, should you want to update/upgrade Shlink, you can do this by stopping the container, removing it, pulling down the latest version, and then starting it again with the docker run command used above. This should connect it to your MySQL database where all the links reside.

docker stop my_shlink
docker rm my_shlink
docker pull shlinkio/shlink:stable
# Run docker run above