mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1246 words
6 minutes
Making my Lanyard Instances Work Better
2026-06-16

Making My Lanyard Instances Work Better#

I wanted to add a nice section to my About page to show my Discord status, but I wanted to host it for myself. Thanks to Lanyard, I was able to do that, but also do a bit more than I originally planned to.

What’s Lanyard?#

Firstly, let’s talk about Lanyard. It’s a Discord bot which runs a service that allows you to get anyone’s Discord Rich Presence in a nice, clean JSON format (if you’re in the same server as the bot and are sharing your presence data publicly). It’s really useful especially since you can do a lot with Rich Presence, but Discord tends to keep those kind of features locked in their ecosystem, which is understandable but also a bit frustrating if you want to share that info out onto the open.

Phineas
/
lanyard
Waiting for api.github.com...
00K
0K
0K
Waiting...

However, just a JSON object isn’t too useful if you want to mimic a Discord profile and show what you’re doing live across the internet, which is where cnrad’s project comes in, which takes in a JSON object from a Lanyard instance and makes a nice image that you can share anywhere, which also links to your Discord profile.

cnrad
/
lanyard-profile-readme
Waiting for api.github.com...
00K
0K
0K
Waiting...

A few weeks ago I wanted to selfhost both, so I got to work with the backend first.

Setting up my own instance#

Lanyard makes it quite easy to self-host, with an official Docker image and a Docker Compose file which needs little modification to get up and running. I’ll walk through the steps I used to self-host Lanyard on my own VPS.

  1. Make a new folder called “lanyard” in my VPS’ root directory
mkdir lanyard && cd lanyard
  1. Make a docker-compose.yml file and copy and save the sample file
nano docker-compose.yml
  1. Setup your Discord application for the service to scan users’ profiles with

    1. Go to discord.dev and click on “Developer Portal”
    2. Click on “Applications,” then click “New Application”
    3. Name the application what you want, then click on the “Bot” tab
    4. Scroll down to “Privileged Gateway Intents” and turn on “Presence Intent” and “Server Members Intent”
    5. Go to the “Installation” tab on the left side and setup your bot to look like this installation-of-bot.png
    6. Go back to the “Bot” tab and click “Reset Token”, then copy the token that you get after authenticating
  2. Go back to your docker-compose.yml file, and edit it so that the “Bot” field has the token you copied in Step 3.6

  3. Install Docker

  4. Run docker compose up -d and wait for everything to run.

Now, if everything goes well, it should just start, and the logs should look something like this

lanyard_redis | 1:M 24 May 2026 05:07:44.386 * Server initialized
lanyard_redis | 1:M 24 May 2026 05:07:44.480 * Ready to accept connections tcp
lanyard |
lanyard | 18:27:14.601 [debug] Discord: Heartbeat ACK
lanyard |
lanyard | 18:27:55.852 [debug] Discord: Heartbeat ACK
lanyard |
lanyard | 18:28:37.103 [debug] Discord: Heartbeat ACK
lanyard |
lanyard | 18:29:18.367 [debug] Discord: Heartbeat ACK
lanyard |
lanyard | 18:29:59.617 [debug] Discord: Heartbeat ACK
lanyard |
lanyard | 18:30:40.868 [debug] Discord: Heartbeat ACK

However, for me there’s always something that doesn’t go right. What I speculate happened was some low-level quirk between the official Docker image and my ARM64 VPS, which was running the image through QEMU emulation.

lanyard | 04:27:53.966 [warning] Discord: Websocket disconnected with reason {:remote, :closed}, will attempt resume
lanyard |
lanyard | 04:27:53.966 [info] Discord: Websocket closed in state %{token: "hidden", agent_seq_num: #PID<0.2280.0>, heartbeat_pid: #PID<0.2284.0>, client_pid: #PID<0.2279.0>} with reason {:remote, :closed}
lanyard |
lanyard | 04:27:53.966 [error] :gen_statem #PID<0.2279.0> terminating
lanyard | ** (stop) {:remote, :closed}
lanyard | (stdlib 7.3) gen_statem.erl:3870: :gen_statem.loop_state_callback_result/11
lanyard | (stdlib 7.3) proc_lib.erl:333: :proc_lib.init_p_do_apply/3
lanyard | Queue: [info: {:ssl_closed, {:sslsocket, #Port<0.94>, #PID<0.2283.0>, #PID<0.2282.0>, :gen_tcp, :tls_gen_connection, #Reference<0.821752807.278265857.199710>, :undefined}}]
lanyard | Postponed: []
lanyard | State: {:disconnected, {:context, {:websocket_req, :wss, ~c"gateway.discord.gg", 443, ~c"/?v=10&encoding=json", :infinity, :undefined, 1, {:sslsocket, #Port<0.94>, #PID<0.2283.0>, #PID<0.2282.0>, :gen_tcp, :tls_gen_connection, #Reference<0.821752807.278265857.199710>, :undefined}, {:transport, :ssl, :ssl, :ssl_closed, :ssl_error, [mode: :binary, active: true, verify: :verify_none, packet: 0]}, "+/3IBk47T7Ve1MZDgJqEEQ==", :undefined, 1, :undefined, :undefined, :undefined}, {:transport, :ssl, :ssl, :ssl_closed, :ssl_error, [mode: :binary, active: true, verify: :verify_none, packet: 0]}, [], {:wss, ~c"gateway.discord.gg", 443, ~c"/?v=10&encoding=json"}, {Lanyard.Gateway.Client, %{token: "hidden", agent_seq_num: #PID<0.2280.0>, heartbeat_pid: #PID<0.2284.0>, client_pid: #PID<0.2279.0>}}, "", false, 0}}
lanyard | Callback mode: :state_functions, state_enter: false
lanyard |
lanyard | 04:27:53.967 [info] Discord bot running on #PID<0.2286.0>
lanyard |
lanyard | 04:27:54.159 [debug] Discord: Hello
lanyard |
lanyard | 04:27:54.238 [debug] Discord: Heartbeat ACK

Now I had no clue what was going on, and it took a lot of debugging. I tried building from source and running a forked image which natively built the source for ARM64, but nothing worked. Thankfully, with some help from the nice folks in the Lanyard Discord server, I was able to finally get it up and running. What finally seemed to help was adding the Message Content Intents and resetting the bot token again.

NOTE

I wouldn’t generally recommend doing this, since this technically violates least privilege (since the bot is passive ), but so far it seems to work so I’m not changing anything.

With that, the backend is done, and you can see my current status here. Here’s what a JSON response from the Lanyard API looks like (with me writing this article as an activity):

{
"data": {
"kv": {
},
"discord_user": {
"avatar": "a_76260781e26b69209c1d4a7da62c4611",
"avatar_decoration_data": {
"asset": "a_f824f7f3d04732ce5e2ff0f3f05d1937",
"expires_at": null,
"sku_id": "1427463138634109027"
},
"bot": false,
"collectibles": {
"nameplate": {
"asset": "nameplates/orb/infinite_swirl/",
"expires_at": null,
"label": "COLLECTIBLES_ORB_INFINITE_SWIRL_NP_A11Y",
"palette": "violet",
"sku_id": "1427463138646954035"
}
},
"discriminator": "0",
"display_name": "OrionBlur",
"display_name_styles": {
"colors": [
4184013,
936427
],
"effect_id": 2,
"font_id": 8
},
"global_name": "OrionBlur",
"id": "1433970978433798206",
"primary_guild": {
"badge": "1833a3497f526a5a60d6132ff85eff56",
"identity_enabled": true,
"identity_guild_id": "819285379982557265",
"tag": "LNYD"
},
"public_flags": 256,
"username": "orionblur"
},
"activities": [
{
"content_classification": {
"data": null,
"loaded": true
},
"created_at": 1781634949013,
"emoji": {
"animated": false,
"id": "1515858418357764307",
"name": "sonic"
},
"id": "custom",
"name": "Custom Status",
"session_id": "51f8578345f7f3af0c45b25201dc277b",
"state": "\"All for the speed I was born to be, Born to be! Born to Be!\"",
"type": 4
},
{
"application_id": "1274044898504478811",
"assets": {
"large_image": "mp:external/RnW5ZhQqtBA7stpKlQ4yKycOON6kpA6mjMZiJfCbw7Q/https/cdn.jsdelivr.net/gh/Azn9/Jetbrains-Discord-Integration%40develop/icons/data/themes/material/languages/markdown.png",
"large_text": "Markdown",
"small_image": "mp:external/RaOwss1Ib1G_VrLUqgjjk2rMb4F1jNRrOCg51jKu1Q8/https/cdn.jsdelivr.net/gh/Azn9/Jetbrains-Discord-Integration%40develop/icons/data/themes/material/applications/webstorm.png",
"small_text": "2026.1.3"
},
"content_classification": {
"data": null,
"loaded": true
},
"created_at": 1781635170722,
"details": "personal-website",
"id": "a0f86d84916b348a",
"name": "WebStorm",
"platform": "desktop",
"session_id": "51f8578345f7f3af0c45b25201dc277b",
"state": "Editing fixing-lanyard/index.md",
"timestamps": {
"start": 1781631320000
},
"type": 0
}
],
"discord_status": "dnd",
"active_on_discord_web": false,
"active_on_discord_desktop": true,
"active_on_discord_mobile": false,
"active_on_discord_embedded": false,
"active_on_discord_vr": false,
"listening_to_spotify": false,
"spotify": null
},
"success": true
}

With that out of the way, the harder part was hosting my own visualizer.

Setting up the visualizer#

Hosting the visualizer had a different set of problems, but actually getting it off the ground didn’t have as many hitches as I expected, which is always a pleasant surprise.

Anyways, here’s the steps I used to selfhost the Lanyard visualizer

  1. Fork and pull cnrad’s repo
  2. Open the project in an IDE (I use Webstorm, but VS Code also works)
  3. Go to src/app/api/[id]/route.ts, and go to const lanyardData
  4. Change the url from the default API to your own selfhosted URl (mine is lanyard.orionblur.com)
  5. Run bun install and then bun next build; if everything is well it’ll compile a distribution-ready website
  6. Run npx wrangler login and get everything set up on Cloudflare’s end (I bought my domain through them so I just needed to make a subdomain)
  7. Run npx wrangler deploy and if everything goes right, your project should rebuild and be published on its own subdomain (For me it’s lanyard-profile.orionblur.com)!

Fixing and expanding the visualizer#

While this did work, there was one glaring issue I had with the default implementation: images would often not load. This is because Discord blocks Cloudflare Workers from accessing their media domains, which is to prevent abuse but also seems a bit shortsighted in my opinion, since there are many other solutions which could do the same thing. The easiest way for me to fix this was to host a really simple proxy on my VPS, since that has a dedicated IP address and wouldn’t be beholden to the same issues as Cloudflare Workers’ shared IP pools. Thankfully, this is really easy with a simple nginx block, which I’ve shown below

server {
listen 443 ssl http2;
server_name proxy.orionblur.com;
ssl_certificate "...";
ssl_certificate_key "...";
location = /favicon.ico {
return 204;
}
location = /apple-touch-icon.png {
return 204;
}
location = / {
return 200 "Content proxy is running.\nUsage: /proxy?url=https://example.com/image.jpg\n";
add_header Content-Type text/plain;
}
location /proxy {
# Require ?url=
if ($arg_url = "") {
return 400 "Missing url parameter";
}
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
set $target $arg_url;
proxy_pass $target;
proxy_set_header User-Agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header CF-Connecting-IP "";
proxy_set_header Forwarded "";
proxy_set_header Host $proxy_host;
proxy_ssl_server_name on;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering on;
}
}

After some configuring of my domain’s DNS, all I needed to do was change the code that fetches images to use my proxy, and now images were loading again, straight from Discord!

Now that the images were loading, I wanted to make my profile picture animated. While the original code by cnrad did support using gif and webp, a while back Discord changed how their asset system worked and now requires the “animated=true” flag in the GET request if you want to get an actual animated avatar or banner. After making that change, my avatar was now showing as animated.

While I was scrolling cnrad’s repo for PRs or issues casually, I noticed a PR by Sahaj33-op to add multiple activities in the card display. This seemed really good, since even though Lanyard returned all activites on a user’s profile, only 1 would show on the default display. I merged it into my own fork, but unfortunately not without accidentally making a PR.

The final thing I worked on myself was adding an artist picture for Apple Music. All activites on Discord can have both a big image and small image, and since Apple Music’s Discord RPC would have a small image (usually for the artist), I figured you could treat it like another activity and fetch that image if available. Luckily, the change just consisted of adding an extra field where applicable and editing a bit of the tsx file that renders the card to group together the album and artist image, like how Sahaj did so for all the activity images.

With that all out of the way, here’s what it finally looks like, fixed

final-product.png

I had to take a screenshot since the SVG (which is how the program renders the image) is really large.

Conclusion#

This is a really fun thing to work on, and there’s even more I want to work on. The next main things I want to add is visualizing the nameplate a user has on the SVG and adding a “Last Seen” field on the backend which can be shown on the frontend (which was nearly completed in a PR dating back a year, but never merged). If you have any ideas on how I could improve my implementations or just want to chat, feel free to talk to me on Discord or Twitter!

Share

If this article helped you, please share it with others!

Making my Lanyard Instances Work Better
https://orionblur.com/posts/fixing-lanyard/
Author
OrionBlur
Published at
2026-06-16
License
CC BY-NC-SA 4.0

Table of Contents