Host a Discord bot on AWS with slash commands over HTTPS, signature checks and a queue for slow commands, or as a gateway bot on Fargate, Lambda or EC2.
Discord bots come in two shapes, and the shape decides how you host one on AWS. A gateway bot keeps a WebSocket open to Discord and needs a process that never stops. An interactions bot receives slash commands as signed HTTPS requests, which makes it an ordinary web endpoint that can scale with traffic and restart without dropping a connection, as described in Discord's interactions documentation.
This guide deploys an interactions bot written in TypeScript to AWS with Encore, including signature verification and a queue for commands that take longer than three seconds, and then compares discord.js on Fargate, Lambda with a Function URL, and EC2. The Encore endpoint below was run with encore run against locally signed requests, and the outputs are from that run.
Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. For TypeScript it is a backend framework with built-in infrastructure, so the interactions endpoint, the queue for slow commands and the secrets are declared in code, and the same code runs locally and on AWS.
Run in your terminal to get started locally.
// bot/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("bot");
Discord signs every request with Ed25519 over the timestamp header plus the raw body, so the endpoint is a raw endpoint that reads the body itself before parsing it. The public key comes from a secret, and slow commands go to a Pub/Sub topic:
// bot/interactions.ts
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { Subscription, Topic } from "encore.dev/pubsub";
import { CacheCluster, CacheKeyExists, StringKeyspace, expireIn } from "encore.dev/storage/cache";
import { createPublicKey, verify } from "node:crypto";
import type { IncomingMessage } from "node:http";
// The public key from the Discord Developer Portal (General Information).
const discordPublicKey = secret("DiscordPublicKey");
const discordAppId = secret("DiscordApplicationId");
// Discord gives the key as 32 raw bytes in hex. Node needs it wrapped in
// an SPKI DER header to build an Ed25519 key object.
function ed25519Key(hex: string) {
const der = Buffer.concat([
Buffer.from("302a300506032b6570032100", "hex"),
Buffer.from(hex, "hex"),
]);
return createPublicKey({ key: der, format: "der", type: "spki" });
}
const maxSkewSeconds = 5 * 60;
// Shared across instances. A replay within the timestamp window hits this
// key on whichever worker receives it.
const interactionCache = new CacheCluster("interaction-ids", {
evictionPolicy: "noeviction",
});
const seenReports = new StringKeyspace<{ id: string }>(interactionCache, {
keyPattern: "interaction/:id",
// Retain the claim through the full future-skew acceptance horizon.
defaultExpiry: expireIn((maxSkewSeconds * 2 + 1) * 1000),
});
function timestampFresh(timestamp: string): boolean {
const seconds = Number(timestamp);
if (!Number.isFinite(seconds)) return false;
return Math.abs(Date.now() / 1000 - seconds) <= maxSkewSeconds;
}
async function claimReport(id: string): Promise<"claimed" | "duplicate" | "unavailable"> {
try {
await seenReports.setIfNotExists({ id }, "1");
return "claimed";
} catch (err) {
if (err instanceof CacheKeyExists) return "duplicate";
// A full cache must not drop the interaction. Discord retries a 503.
return "unavailable";
}
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
interface SlowCommand {
token: string; // interaction token, valid for 15 minutes
user: string;
interactionId: string;
}
// Work that takes longer than Discord's 3 second window goes through a queue.
const slowCommands = new Topic<SlowCommand>("slow-commands", {
deliveryGuarantee: "at-least-once",
});
const finishedReports = new Set<string>();
const _ = new Subscription(slowCommands, "finish-report", {
handler: async (cmd) => {
// A redelivery edits the same original message, so it does not post twice.
if (finishedReports.has(cmd.interactionId)) return;
const content = `Report for ${cmd.user} is ready.`;
const res = await fetch(
`https://discord.com/api/v10/webhooks/${discordAppId()}/${cmd.token}/messages/@original`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
if (!res.ok) throw new Error(`discord returned ${res.status}`); // retried
finishedReports.add(cmd.interactionId);
},
retryPolicy: { maxRetries: 3 },
});
// Set this URL as the Interactions Endpoint URL in the Developer Portal.
export const interactions = api.raw(
{ expose: true, method: "POST", path: "/discord/interactions" },
async (req, resp) => {
const body = await readBody(req);
const signature = req.headers["x-signature-ed25519"];
const timestamp = req.headers["x-signature-timestamp"];
const valid =
typeof signature === "string" &&
typeof timestamp === "string" &&
verify(
null,
Buffer.from(timestamp + body),
ed25519Key(discordPublicKey()),
Buffer.from(signature, "hex"),
);
if (!valid || !timestampFresh(timestamp)) {
resp.writeHead(401).end("invalid request signature");
return;
}
const interaction = JSON.parse(body);
const reply = (payload: unknown) => {
resp.writeHead(200, { "Content-Type": "application/json" });
resp.end(JSON.stringify(payload));
};
if (interaction.type === 1) return reply({ type: 1 }); // PING -> PONG
if (interaction.type === 2) {
const user = interaction.member?.user?.username ?? interaction.user?.username;
switch (interaction.data.name) {
case "ping":
return reply({ type: 4, data: { content: `Pong, ${user}!` } });
case "report": {
const id = typeof interaction.id === "string" ? interaction.id : "";
if (id === "") {
resp.writeHead(400).end("missing interaction id");
return;
}
const claim = await claimReport(id);
if (claim === "unavailable") {
resp.writeHead(503).end("try again");
return;
}
if (claim === "duplicate") return reply({ type: 5 });
// Keep the claim if publish's result is lost. A retry must not enqueue again.
await slowCommands.publish({ token: interaction.token, user, interactionId: id });
return reply({ type: 5 }); // deferred: shows "thinking..."
}
}
}
resp.writeHead(400).end("unknown interaction");
},
);
/ping answers inline with response type 4. /report publishes a message and answers with type 5 well inside the three second window, and the subscription edits the original message when the work is done, with retries if the Discord API call fails.
For local runs, put the values in .secrets.local.cue next to encore.app. Signing a test request with a locally generated Ed25519 key pair whose public half is in that file exercises the same path Discord uses:
The last request returned a 401, which is what Discord's endpoint check expects for a bad signature. The local development dashboard traces the /report publish and the subscription run that follows it.
Set the production values with encore secret set --type prod DiscordPublicKey and the same for DiscordApplicationId. Connect your AWS account in the Encore dashboard under Integrations > Connect Cloud, as described in Connect your cloud account, create an environment on AWS, and run git push encore. Encore provisions the compute behind an Application Load Balancer, SNS and SQS with a dead-letter queue for the topic, a cache cluster for interaction ids, and Secrets Manager entries for the secrets, and it provisions an IAM role for the service, as listed in the AWS infrastructure docs.
Paste https://<your-api-url>/discord/interactions into Interactions Endpoint URL in the Developer Portal and save, and Discord runs its PING and bad-signature checks against the deployed endpoint. Then register the commands once with the bot token:
Best fit: slash command bots that should run in your own AWS account with signature checks, deferred replies through a queue, and secrets declared in the same TypeScript code as the commands.
A gateway bot built with discord.js logs in over a WebSocket and receives every event it has intents for, including messages and member updates. On AWS it runs as an ECS service on Fargate with a desired count of one per shard, the bot token in Secrets Manager referenced from the task definition, and outbound access through a NAT gateway or a public IP, since there is no inbound traffic and no load balancer. Two tasks with the same shard both receive every event, so deployments need a stop-then-start strategy instead of the default rolling update, which brings a short gap on every release.
Best fit: bots that read messages, track presence or play audio, which only the gateway provides.
An interactions endpoint fits Lambda: a Function URL gives the function an HTTPS address without API Gateway, and the handler verifies the signature and returns the response JSON. Cold starts count against the three second window, so keep the bundle small or use provisioned concurrency for the first request after idle. Deferred commands need a second function or an SQS queue, because the first invocation ends when it returns the type 5 response.
Best fit: low-traffic slash command bots where per-request pricing matters and cold starts stay well under three seconds.
The simplest gateway setup is a small EC2 instance running the bot under pm2 or systemd, which restarts it on crash and boot. It works for hobby bots and costs a few dollars a month on a small instance. You own operating system updates, the restart on instance retirement and deployments over SSH, and the bot goes down whenever the instance does.
Best fit: a single hobby gateway bot where a few minutes of downtime is acceptable.
Pick Encore for slash command bots that should run in your own AWS account with signature checks, queues for slow commands and secrets declared in code. Pick discord.js on Fargate when the bot needs the gateway, Lambda with a Function URL for low-traffic interactions bots, and EC2 for a single hobby bot.
For a bot built on slash commands, run an HTTPS interactions endpoint on Fargate or Lambda and set it as the Interactions Endpoint URL in the Discord Developer Portal. It scales with requests and needs no open gateway connection. Bots that react to every message or to presence events need the gateway, which means a long-running process on Fargate or EC2.
Write the interactions endpoint as an api.raw endpoint that verifies the Ed25519 signature, store the public key with secret(), and send slow commands to a Pub/Sub topic so the endpoint can reply with a deferred response inside three seconds. Connect your AWS account in the Encore dashboard, create an environment and run git push encore. Encore provisions the compute, a load balancer and SNS and SQS for the topic in your own AWS account.
Discord checks the endpoint when you save it, sending a PING that must get a type 1 response, and requests with bad signatures that must get a 401. An endpoint that skips signature verification, reads a parsed body instead of the raw body, or responds slower than a few seconds fails the check.
The initial response has to arrive within three seconds, so for anything slower respond with type 5 (a deferred response, which shows a thinking indicator) and edit the original message later through the interaction webhook. The interaction token stays valid for 15 minutes.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.