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.
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.
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.
- Make a new folder called “lanyard” in my VPS’ root directory
mkdir lanyard && cd lanyard- Make a docker-compose.yml file and copy and save the sample file
nano docker-compose.yml-
Setup your Discord application for the service to scan users’ profiles with
- Go to discord.dev and click on “Developer Portal”
- Click on “Applications,” then click “New Application”
- Name the application what you want, then click on the “Bot” tab
- Scroll down to “Privileged Gateway Intents” and turn on “Presence Intent” and “Server Members Intent”
- Go to the “Installation” tab on the left side and setup your bot to look like this

- Go back to the “Bot” tab and click “Reset Token”, then copy the token that you get after authenticating
-
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
-
Run
docker compose up -dand 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 initializedlanyard_redis | 1:M 24 May 2026 05:07:44.480 * Ready to accept connections tcplanyard |lanyard | 18:27:14.601 [debug] Discord: Heartbeat ACKlanyard |lanyard | 18:27:55.852 [debug] Discord: Heartbeat ACKlanyard |lanyard | 18:28:37.103 [debug] Discord: Heartbeat ACKlanyard |lanyard | 18:29:18.367 [debug] Discord: Heartbeat ACKlanyard |lanyard | 18:29:59.617 [debug] Discord: Heartbeat ACKlanyard |lanyard | 18:30:40.868 [debug] Discord: Heartbeat ACKHowever, 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 resumelanyard |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> terminatinglanyard | ** (stop) {:remote, :closed}lanyard | (stdlib 7.3) gen_statem.erl:3870: :gen_statem.loop_state_callback_result/11lanyard | (stdlib 7.3) proc_lib.erl:333: :proc_lib.init_p_do_apply/3lanyard | 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: falselanyard |lanyard | 04:27:53.967 [info] Discord bot running on #PID<0.2286.0>lanyard |lanyard | 04:27:54.159 [debug] Discord: Hellolanyard |lanyard | 04:27:54.238 [debug] Discord: Heartbeat ACKNow 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.
NOTEI 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
- Fork and pull cnrad’s repo
- Open the project in an IDE (I use Webstorm, but VS Code also works)
- Go to src/app/api/[id]/route.ts, and go to
const lanyardData - Change the url from the default API to your own selfhosted URl (mine is lanyard.orionblur.com)
- Run
bun installand thenbun next build; if everything is well it’ll compile a distribution-ready website - Run
npx wrangler loginand get everything set up on Cloudflare’s end (I bought my domain through them so I just needed to make a subdomain) - Run
npx wrangler deployand 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

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!
If this article helped you, please share it with others!





