Shlink, or: the URL shortener that had no SSO and I deployed it anyway ๐
I needed a URL shortener. Not because I have millions of clicks to track, but because every time I share a blog post link on social media, the URL looks like this:
1
https://gulasz101.github.io/posts/apfel-pi-the-tiniest-brain-in-the-terminal/
Thatโs 73 characters of โI dare you to click this on mobile.โ So I went looking for a self-hosted URL shortener that fits the homelab GitOps assembly line, and I found Shlink. And then Shlink taught me a lesson about SSO assumptions. ๐
What is Shlink and why bother?
Shlink is a mature, self-hosted URL shortener with a REST API and an official web client. Itโs PHP under the hood (RoadRunner, not Apache โ more on that later), stores everything in Postgres, and has visit tracking with geolocation if you feed it a MaxMind license key.
Why Shlink specifically?
- Stateless container. All durability lives in Postgres or object storage. The pod itself is disposable.
- REST API. I can create short URLs programmatically โ from cron jobs, from Hermes agents, from social media automation.
- Fits the pattern. One service, one CNPG database, one Cloudflare Tunnel, one set of alerts. This is the homelab assembly line.
- Good blog material. It demonstrates how to deploy an app that has no native SSO without breaking the homelabโs SSO-first posture.
That last point turned out to be the interesting one. ๐ธ
What I learned from the docs (before touching any YAML)
Before writing a single manifest, I indexed the Shlink documentation into the homelab docs-mcp-server. Hereโs what I found:
- No native SSO/OIDC. GitHub issue shlinkio/shlink#1983 is still there to look at as of v5.1.5. The application authenticates via API keys only.
- Database: external Postgres strongly recommended. SQLite is for testing only.
- Runtime: the Docker image uses RoadRunner, so background tasks (GeoLite download, visit geolocation) run inside the container. No separate CronJob needed.
- Reverse proxy: must forward
Host,X-Forwarded-For, andX-Forwarded-Proto.TRUSTED_PROXIESmust be configured or every visit appears to come from the Cloudflare tunnel IP range. - Health:
/rest/healthrequires no authentication. - Metrics: Shlink does not expose Prometheus-format app metrics. We only get
/rest/health+ kubelet/container metrics. - Web client: can be hosted at
https://app.shlink.ioand pointed at our public API, or self-hosted.
Shlink has no Prometheus /metrics endpoint. If youโre used to LGTM dashboards showing per-service request rates, error budgets, and latency histograms โ Shlink wonโt give you that. You get container CPU/memory, health-check up{}, and log-based alerts. Thatโs it.
The architecture
Same GitOps pattern as every other service in the homelab:
graph TD
A[Internet] --> B[Cloudflare Edge TLS]
B --> C[cloudflared-shlink pod]
C --> D[shlink-backend service :8080]
D --> E[Shlink pod v5.1.5]
E --> F[CNPG Postgres shlink-db]
F --> G[local-path NVMe 5Gi]
F --> H[MinIO S3 cnpg-backups/shlink]
E --> I[app.shlink.io web client]
I --> D
The file layout in the homelab-2nd repo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apps/shlink/
โโโ namespace.yaml
โโโ shlink-helm-repository.yaml
โโโ shlink-helm-release.yaml
โโโ postgres-cluster.yaml
โโโ objectstore.yaml
โโโ scheduled-backup.yaml
โโโ shlink-db-credentials.sops.yaml
โโโ shlink-minio-backup-creds.sops.yaml
โโโ shlink-tunnel-token.sops.yaml
โโโ shlink-tunnel-ingress-configmap.yaml
โโโ cloudflared-shlink-deployment.yaml
โโโ shlink-initial-api-key.sops.yaml
โโโ shlink-mattermost-webhook-url.sops.yaml
โโโ shlink-alertmanager-config.yaml
โโโ shlink-prometheus-rules.yaml
โโโ shlink-loki-rule.yaml
โโโ shlink-dashboard-configmap.yaml
Plus an update to apps/kustomization.yaml to include the new directory.
Step 1: The database (CNPG Postgres)
Every persistent service in the homelab gets a CloudNativePG Postgres cluster. Shlink is no exception. One instance on local-path NVMe for live data, backups and WAL archiving to OMV MinIO for durability.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: shlink-db
namespace: shlink
spec:
instances: 1
bootstrap:
initdb:
database: shlink
owner: shlink
secret:
name: shlink-db-credentials
storage:
size: 5Gi
storageClass: local-path
backup:
barmanObjectStore:
destinationPath: s3://cnpg-backups/shlink/
endpointURL: http://nas.example.com:9000
s3Credentials:
accessKeyId:
name: shlink-minio-backup-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: shlink-minio-backup-creds
key: ACCESS_SECRET_KEY
wal:
compression: gzip
data:
compression: gzip
scheduledBackup:
- name: daily-backup
schedule: "0 3 * * *"
backupOwnerReference: self
The barmanObjectStore section means every WAL file gets shipped to MinIO in real time. If the NVMe drive dies, I can restore from S3 to the last transaction. This is the same backup pattern I use for Authentik, OpenGist, and every other stateful service.
The daily scheduled backup uses the Barman Cloud CNPG plugin:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: shlink-db-daily
namespace: shlink
spec:
schedule: "0 3 * * *"
backupOwnerReference: self
cluster:
name: shlink-db
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
immediate: true
Step 2: The HelmRelease
Thereโs no official Shlink Helm chart. The community chart christianhuth/shlink-backend is a thin wrapper around a Deployment โ which is exactly what I want. No hidden templates, no surprise ConfigMaps, just the app.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: shlink-backend
namespace: shlink
spec:
interval: 1h
chart:
spec:
chart: shlink-backend
version: "11.8.0"
sourceRef:
kind: HelmRepository
name: christianhuth
namespace: flux-system
interval: 1h
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
replicaCount: 1
image:
tag: "5.1.5"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
ingress:
enabled: false
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/rest/health"
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
config:
database:
driver: postgres
host: shlink-db-rw.shlink.svc.cluster.local
port: 5432
auth:
database: shlink
username: shlink
existingSecret: shlink-db-credentials
general:
defaultDomain: shlink.example.com
isHttpsEnabled: true
memoryLimit: "512M"
timezone: "Europe/Berlin"
geolite:
licenseKey: ""
skipInitialDownload: true
urlShortening:
autoResolveTitles: true
defaultShortCodesLength: 5
extraEnv:
- name: TRUSTED_PROXIES
value: "10.0.0.1/12,10.0.0.0/8,10.0.0.2/16"
- name: INITIAL_API_KEY
valueFrom:
secretKeyRef:
name: shlink-initial-api-key
key: INITIAL_API_KEY
A few things worth pointing out:
ingress.enabled: falseโ we use Cloudflare Tunnels, not Kubernetes Ingress. Every service in the homelab does this.prometheus.io/path: "/rest/health"โ since Shlink has no/metricsendpoint, we scrape the health endpoint. This gives usup{}and nothing else from the app itself.TRUSTED_PROXIESโ without this, every visit shows up as coming from the Cloudflare tunnel IP. Shlink needs to know which proxy ranges to trust so it can readX-Forwarded-For.INITIAL_API_KEYโ generated, SOPS-encrypted, and injected viasecretKeyRef. The API key is the only admin credential, so it goes straight into SOPS + 1Password.- GeoLite2 skipped โ I donโt have a MaxMind license key yet. Visit geolocation will show โunknownโ until I get one.
The chart expects the DB secret key database-password, but CNPGโs initdb expects password. Include both keys in the same SOPS-encrypted secret or Shlink will fail to connect on first boot. Ask me how I know. ๐
Step 3: The Cloudflare Tunnel
One tunnel, one pod, one config reminder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared-shlink
namespace: shlink
labels:
app.kubernetes.io/name: cloudflared-shlink
app.kubernetes.io/component: tunnel
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: cloudflared-shlink
template:
metadata:
labels:
app.kubernetes.io/name: cloudflared-shlink
app.kubernetes.io/component: tunnel
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- run
- --token
- $(TUNNEL_TOKEN)
- --url
- http://shlink-backend.shlink.svc.cluster.local:8080
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: shlink-tunnel-token
key: token
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
The tunnel token is SOPS-encrypted in shlink-tunnel-token.sops.yaml. The public hostname rule (shlink.example.com โ http://shlink-backend.shlink.svc.cluster.local:8080) is configured in the Cloudflare Zero Trust dashboard, and a ConfigMap in the repo documents it so future-me doesnโt forget:
1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: ConfigMap
metadata:
name: shlink-tunnel-ingress
namespace: shlink
data:
public-hostname: "shlink.example.com"
origin: "http://shlink-backend.shlink.svc.cluster.local:8080"
note: "Configure this origin in Cloudflare Zero Trust -> Networks -> Tunnels -> shlink"
Step 4: Observability (the honest(ish) part)
Hereโs the thing: Shlink doesnโt expose Prometheus metrics. No /metrics endpoint, no request rate, no error budget, no latency histogram. The dashboard and alerts rely entirely on container-level metrics from cAdvisor and kubelet.
Prometheus rules (container-level only)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: shlink-resource-alerts
namespace: shlink
labels:
release: kube-prometheus-stack
spec:
groups:
- name: shlink.resources
interval: 30s
rules:
- alert: ShlinkCPUAboveRequest
expr: |
sum by (pod, container, namespace) (
rate(container_cpu_usage_seconds_total{namespace="shlink", container!=""}[5m])
) >
sum by (pod, container, namespace) (
kube_pod_container_resource_requests{namespace="shlink", resource="cpu", container!=""}
)
for: 5m
labels:
severity: warning
namespace: shlink
annotations:
summary: "shlink/ CPU above request"
- alert: ShlinkMemoryAboveRequest
expr: |
sum by (pod, container, namespace) (
container_memory_working_set_bytes{namespace="shlink", container!=""}
) >
sum by (pod, container, namespace) (
kube_pod_container_resource_requests{namespace="shlink", resource="memory", container!=""}
)
for: 5m
labels:
severity: warning
namespace: shlink
annotations:
summary: "shlink/ memory above request"
These are the same four rules I deploy for every namespace: CPU above request, memory above request, CPU above 90% of limit, memory above 90% of limit. They donโt tell me why Shlink is using resources โ just that it is.
Loki log alerts (the real signal)
Since there are no app metrics, log-based alerts do the heavy lifting:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
apiVersion: v1
kind: ConfigMap
metadata:
name: shlink-loki-rules
namespace: observability
labels:
loki_rule: "true"
data:
shlink-http-errors.yaml: |
groups:
- name: shlink.http-errors
interval: 1m
rules:
- alert: ShlinkNon2xxResponses
expr: |
sum by (pod, namespace) (
count_over_time(
{namespace="shlink", pod=~"shlink-backend-.*"}
|~ "status\":\s*[3-9]\d{2}"
[5m]
)
) > 0
for: 2m
labels:
severity: warning
namespace: shlink
annotations:
summary: "Shlink endpoint returned non-2xx status"
description: " logged non-2xx HTTP responses in the last 5 minutes."
This regex-matches log lines containing "status": 3xx/4xx/5xx and fires if any show up in a 5-minute window. Itโs not as precise as a Prometheus histogram, but it catches real errors.
Alertmanager โ Mattermost
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: monitoring.coreos.com/v1alpha1
kind: AlertmanagerConfig
metadata:
name: shlink-mattermost-alerts
namespace: shlink
labels:
release: kube-prometheus-stack
spec:
route:
receiver: shlink-mattermost
matchers:
- name: namespace
value: shlink
matchType: "="
receivers:
- name: shlink-mattermost
webhookConfigs:
- urlSecret:
name: shlink-mattermost-webhook-url
key: url
sendResolved: true
maxAlerts: 10
Same pattern as every other namespace: alerts route to a Mattermost webhook, resolved alerts get sent too (so I know when things go green again), and the webhook URL is SOPS-encrypted.
SSO โ the hard truth ๐
Hereโs where it gets interesting. The homelab has an SSO-first posture. Authentik is the central identity provider. Every service that supports OIDC gets wired up to it โ Mattermost, Open WebUI, Nextcloud, tldraw.
Shlink does not support OIDC. Not โitโs hard to configureโ โ it literally does not have the feature. The GitHub issue has been open for years. The app authenticates via API keys, period.
So what do you do when a service you want to deploy has no SSO?
Phase 1 (what I did): Use the hosted web client at https://app.shlink.io only from trusted networks / Tailscale. Keep the API key in 1Password. The public API endpoints (/rest/v3/*) and short URL redirects stay public โ thatโs the whole point of a URL shortener. The admin UI is just a web client that talks to the API, so it doesnโt need to be self-hosted.
Phase 2 (future): Deploy an OAuth2 Proxy sidecar in front of a self-hosted shlink-web-client at a separate hostname (something like shlink-admin.example.com) with Authentik OIDC. The proxy enforces login. The web client uses a pre-configured API key from servers.json. Public short URLs stay on shlink.example.com and bypass auth.
The key insight: donโt fight the appโs architecture. Shlink is API-first. The web client is optional and replaceable. Instead of trying to bolt SSO onto the backend, gate the admin UI with a proxy and leave the public API alone.
Verification
After Flux reconciled everything:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Pods healthy?
kubectl get pods -n shlink
# NAME READY STATUS
# shlink-backend-xxx-yyy 1/1 Running
# shlink-db-1 2/2 Running
# cloudflared-shlink-xxx-yyy 1/1 Running
# Internal health check
kubectl exec -n shlink deploy/shlink-backend -- curl -s http://localhost:8080/rest/health
# {"status":"pass","version":"5.1.5"}
# Public health check
curl -s https://shlink.example.com/rest/health
# {"status":"pass","version":"5.1.5"}
# Create a short URL
curl -s -X POST https://shlink.example.com/rest/v3/short-urls \
-H "X-Api-Key: *** \
-H "Content-Type: application/json" \
-d '{"longUrl":"https://example.com","title":"test"}'
It worked. Flux reconciled the HelmRelease, CNPG spun up the Postgres cluster, the Cloudflare Tunnel connected, and the first short URL redirected correctly. ๐
What I learned
- Not every service needs SSO on the backend. Some apps are API-first, and the admin UI is a separate concern. Gate the UI, leave the API public.
- No app metrics? Log alerts are your friend. Loki regex-matching on HTTP status codes catches real errors without a
/metricsendpoint. - The DB secret key mismatch gotcha. The Helm chart expects
database-password, CNPG expectspassword. Put both in the same secret and move on. TRUSTED_PROXIESis not optional. Without it, every visit shows up as the tunnel IP and your analytics are useless.- The homelab assembly line works. Namespace โ CNPG โ HelmRelease โ Cloudflare Tunnel โ SOPS secrets โ Prometheus rules โ Loki rules โ Alertmanager โ Grafana dashboard. Every new service follows the same pattern. Itโs boring, and boring is good. ๐ฅฆ
Whatโs next
- Get a MaxMind GeoLite2 license key so visit geolocation works
- Phase 2: self-hosted
shlink-web-clientbehind OAuth2 Proxy with Authentik OIDC - Wire the Shlink API into the blog publishing pipeline so social media posts automatically get short URLs
- Maybe add a custom short code for the blog:
shlink.example.com/blogโ latest post? Weโll see ๐
Material for this post, and some parts of the post are AI assisted. Models used while working on the deployment and post itself:
- kimi-k2.7 (ollama cloud)
- deepseek-v4-pro-ollama (ollama cloud)
- ideogram4 (self hosted)
- unsloth/Qwen3-VL-8B-Instruct-GGUF (self hosted)
- HauhauCS/Qwen3VL-8B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf (self hosted)


