Build a Discord bot with Serenity (Rust)

A minimal Rust Discord bot with Serenity — a Cargo project, a /ping-style message handler, and the honest truth about compile times on the Rust application, where your code builds on every start.

Serenity is Rust's most established Discord library, and it runs on the Rust application like any other Cargo project — with one big caveat this guide is upfront about: the Rust application compiles your code on every start, and a Discord library pulls in a lot of crates, so that first build is slow. This guide gives you a minimal, correct Serenity bot, then tells the honest truth about build times and how the cache saves you.

At a glance
You need a bot token from Your first Discord bot, and a Falix server running the Rust application (some RAM headroom helps — see below)
Plan Free or premium — but read the compile-time note before choosing free
Time about forty minutes, most of it the first build

Rust on Falix explains the build model and cache in full; this guide is the Serenity-specific build on top of it.

The model, and the honest catch

The Rust application runs cargo run --release when you press Start — it builds from your Cargo.toml and then runs the result. A pure bot needs no port (it dials out to Discord), so that part is simple. The catch is the build:

⚠️ Heads up: Serenity is not a small dependency. A fresh Serenity bot pulls in ~150 crates (Serenity, Tokio, the HTTP and TLS stacks, and their dependencies). On a capable machine that cold build takes roughly a minute; on a free plan's 2.5 GB of shared RAM it is considerably slower, and a big compile can even run out of memory (exit code 137) — the exact pitfall Rust on Falix and Out of memory warn about. You pay this once: cargo caches everything in target/, so after the first successful build, restarts are fast and only your changed code recompiles (a few seconds). A reinstall or application switch clears target/ and brings the long build back.

If you want a bot online in five minutes, discord.js or discord.py get you there faster. Choose Rust when you want Rust — for the type safety, the performance, or the learning.

Step 1 — Cargo.toml

Keep dependencies lean; every extra crate lengthens that build. Serenity plus Tokio (its async runtime) is the minimum:

[package]
name = "serenity-bot"
version = "0.1.0"
edition = "2021"

[dependencies]
serenity = "0.12"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Step 2 — src/main.rs

A minimal bot: read the token from a file, connect, and reply Pong! to !ping.

use std::fs;

use serenity::async_trait;
use serenity::model::channel::Message;
use serenity::model::gateway::Ready;
use serenity::prelude::*;

struct Handler;

#[async_trait]
impl EventHandler for Handler {
    async fn message(&self, ctx: Context, msg: Message) {
        if msg.content == "!ping" {
            if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await {
                println!("Error sending message: {why:?}");
            }
        }
    }

    async fn ready(&self, _ctx: Context, ready: Ready) {
        println!("Listening as {}", ready.user.name);
    }
}

#[tokio::main]
async fn main() {
    // Read the token from token.txt, next to the project (never hard-code it).
    let token = fs::read_to_string("token.txt")
        .expect("could not read token.txt")
        .trim()
        .to_string();

    let intents = GatewayIntents::GUILD_MESSAGES
        | GatewayIntents::DIRECT_MESSAGES
        | GatewayIntents::MESSAGE_CONTENT;

    let mut client = Client::builder(&token, intents)
        .event_handler(Handler)
        .await
        .expect("Error creating client");

    if let Err(why) = client.start().await {
        println!("Client error: {why:?}");
    }
}

The pieces: EventHandler is a trait you implement (the #[async_trait] makes its methods async), overriding message and ready for the events you want. GatewayIntents declares what the bot listens for. Client::builder(token, intents) builds the connection, and client.start() runs it, holding the process open — which is what keeps the server "online."

⚠️ Heads up: MESSAGE_CONTENT is a privileged intent. A !ping message bot reads message text, so you must switch Message Content on under Bot in the Developer Portal — otherwise Discord rejects the login for requesting a disallowed intent. (Prefer slash commands and you can drop that intent; Serenity supports them too, see the docs below.)

Step 3 — Onto the server

Two things go into /home/container:

  1. Your project — Cargo.toml and the src/ folder. Upload via the File Manager or SFTP, or push with Git deploy.
  2. token.txt — create it in the File Manager with your bot token as the only line. The code reads it at startup, so the token stays out of your source and out of Git. Add it to .gitignore.

Step 4 — Start it (and wait out the first build)

Press Start. The console fills with Compiling ... lines — Serenity, Tokio, and their dependency tree, one crate at a time. This is the slow part, and it's working, not stuck. When it finishes you'll see cargo's Finished line, then your bot connects and prints:

Listening as YourBotName

That's the success signal. Later restarts skip almost all of that — the target/ cache means only your own code rebuilds.

💡 Tip: Watch the console during the first build. If it dies partway with exit code 137, that's an out-of-memory kill on the compile — trim dependencies or move to a plan with more RAM. Keeping the crate list short is the single best thing you can do for build time and memory here.

Verify it works

Once you see Listening as …, type !ping in a channel the bot can see. It replies Pong!. That confirms the whole chain: the project compiled, Serenity connected with your token, and the handler fired. No reply? Check that Message Content is enabled in the portal and that the bot has permission to read and send in that channel.

Troubleshooting

  • Client error: Gateway(InvalidAuthentication) — the token in token.txt is wrong or was reset. Copy a fresh one from the Developer Portal (resetting invalidates the old) and save it again. .trim() already handles stray newlines. See Bot appears offline.
  • Login rejected for a disallowed intent — you requested MESSAGE_CONTENT (or another privileged intent) without enabling it on the Bot page. Toggle it on, or drop the intent if you don't need it.
  • First start takes forever — that's the initial compile, not a hang. Watch for Compiling progress; Finished means it's done and later starts are fast. See Rust on Falix.
  • Build dies with exit code 137 — the compiler ran out of memory, common on small plans with a big dependency tree. Trim crates or add RAM. See Out of memory.
  • could not read token.txt — the file isn't in /home/container. Create it in the File Manager with the token as its only line.

Next steps

Was this guide helpful?