I'll be using the RoadRunner runtime method of installation with an external MySQL database.
Install software dependencies
apt install php8.4-{bcmath,cli,curl,intl,mysql} unzip
Create a dedicated Shlink user to run the webserver
useradd -Ms /bin/bash shlink
Create a new database user
CREATE USER 'shlink'@'%' IDENTIFIED BY '****************'
Create a new database
CREATE DATABASE shlink;
Grant all privileges on the new database to the new user
GRANT ALL ON shlink.* TO 'shlink'@'%';
cd into /opt/, and download the desired dist release of Shlink, then unzip it
cd /opt/ && \
wget https://github.com/shlinkio/shlink/releases/download/v4.6.0/shlink4.6.0_php8.4_dist.zip && \
unzip shlink4.6.0_php8.4_dist.zip
Rename the newly exported directory to shlink to make upgrades easier in the future
mv shlink4.6.0_php8.4_dist shlink
cd into the new shlink directory
cd shlink/
Install RoadRunner
php ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr
Change the installation directory ownership
chown shlink:shlink -R ../shlink
Grant write permission recursively to the data directory
chmod 744 -R data/
Run the installation script
./vendor/bin/shlink-installer install
There's lots of options here. Reference this official Shlink document for an explanation of all the options.
Create a systemd service file for RoadRunner to run Shlink
bash -c "cat > /etc/systemd/system/shlink.service" <<'EOF'
[Unit]
Description=Shlink application via RoadRunner
Documentation=https://docs.roadrunner.dev/
[Service]
ExecStart=/opt/shlink/bin/rr serve -c /opt/shlink/config/roadrunner/.rr.yml
Type=notify
# Prevent systemd from terminating the worker processes
KillMode=mixed
# Adjust this to the value in jobs.pool.destroy_timeout
TimeoutStopSec=30
Restart=always
RestartSec=30
User=shlink
Group=shlink
[Install]
WantedBy=default.target
EOF
Reload systemd and enable the new service
systemctl daemon-reload && \
systemctl enable --now shlink.service
I'll be running NGINX locally, on the same server as Shlink, to provide SSL from the local server to my public facing reverse proxy.
Create a new NGINX config
bash -c "cat > /etc/nginx/sites-available/shlink-app.conf" <<'EOF'
server {
server_name s.example.com;
listen 443 http2 ssl;
ssl_certificate /etc/ssl/private/shlink.crt;
ssl_certificate_key /etc/ssl/private/shlink.key;
add_header Strict-Transport-Security "max-age=0;";
charset utf-8;
location / {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# NOTE: I set $http_x_forwarded_for with $remote_addr on my public facing proxy. I wouldn't use $http_x_forwarded_for on the public facing proxy, it can be manipulated
# proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_read_timeout 90s;
}
set_real_ip_from x.x.x.x; #Get the real Client IP from the public facing proxy
}
EOF
Enable the new site
ln -s /etc/nginx/sites-available/shlink-app.conf /etc/nginx/sites-enabled/shlink-app.conf && \
systemctl restart nginx.service
I'll be serving the Shlink Web Client with NGINX
Move into the /var/www/ directory
cd /var/www/
Download, unzip and rename the Shlink Web Client
wget https://github.com/shlinkio/shlink-web-client/releases/download/v4.6.1/shlink-web-client_4.6.1_dist.zip && \
unzip shlink-web-client_4.6.1_dist.zip && \
mv shlink-web-client_4.6.1_dist shlink-web-client
Change the ownership of the web client files
chown www-data:www-data -R shlink-web-client
Create a new NGINX config file for the new site. I've added my own bits of configuration to the official documented config. Adjust as needed.
NOTE: Make sure to either use a different server_name for both the App and Web Client vhosts/sites -OR- use different ports to ensure NGINX can differentiate which site you're trying to reach.
bash -c "cat > /etc/nginx/sites-available/shlink-web-client.conf" <<'EOF'
server {
server_name shlink-web.int.example.com;
listen 443 http2 ssl;
ssl_certificate /etc/ssl/private/shlink.crt;
ssl_certificate_key /etc/ssl/private/shlink.key;
add_header Strict-Transport-Security "max-age=0;";
charset utf-8;
root /var/www/shlink-web-client;
index index.html;
# Expire rules for static content
# HTML files should never be cached. There's only one here, which is the entry point (index.html)
location ~* \.(?:manifest|appcache|html?|xml|json)$ {
expires -1;
}
# Images and other binary assets can be saved for a month
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
expires 1M;
add_header Cache-Control "public";
}
# JS and CSS files can be saved for a year, as they are always hashed. New versions will include a new hash anyway, forcing the download
location ~* \.(?:css|js)$ {
expires 1y;
add_header Cache-Control "public";
}
# servers.json may be on the root, or in conf.d directory
location = /servers.json {
try_files /servers.json /conf.d/servers.json;
}
# When requesting static paths with extension, try them, and return a 404 if not found
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
try_files $uri $uri/ =404;
}
# When requesting a path without extension, try it, and return the index if not found
# This allows HTML5 history paths to be handled by the client application
location / {
try_files $uri $uri/ /index.html$is_args$args;
}
}
EOF
Enable the new site
ln -s /etc/nginx/sites-available/shlink-web-client.conf /etc/nginx/sites-enabled/shlink-web-client.conf && \
systemctl restart nginx.service