Skip to content

Deploying to AWS

  • AWS account with credentials configured (aws configure or environment variables)
  • cordless.toml in your project root
  1. Creates an IAM role with Lambda basic execution permissions (plus any policies you list)
  2. If ratelimit is set, provisions a DynamoDB table for cross-invocation rate-limit coordination and scopes an IAM policy to it
  3. Packages your source into a zip, bundling any extra packages you specify (cached between deploys)
  4. Publishes cordless as a Lambda layer, with PyNaCl included for fast signature verification (reused across deploys if unchanged)
  5. Creates or updates your Lambda function, and sets its CloudWatch log group’s retention to log_retention days (default 30, otherwise logs never expire)
  6. Wires up endpoint: a direct Function URL by default, or an API Gateway HTTP API if you asked for one (needed for a custom domain)
  7. If defer_worker is set, deploys a second worker Lambda (with the same log retention) and wires invoke permissions
  8. If your bot has @bot.cron() handlers, wires EventBridge schedule rules
  9. If keep-warm is set, wires an EventBridge rule that pings the main function directly on a schedule
  10. With --register, pushes your slash commands to Discord

cordless deploy is trusted with real AWS credentials, so it’s worth being explicit about exactly what it grants, to what, and why, before you point it at an account that matters. Two different things are worth separating: what the deployed function itself can do once it’s running, and what your own credentials need to be able to do for deploy/destroy to work at all.

The Lambda execution role cordless creates is deliberately narrow. By default it gets exactly one managed policy:

  • AWSLambdaBasicExecutionRole: write its own CloudWatch logs, nothing else.

Beyond that, permissions are added one scoped, inline policy at a time, only for features you actually turn on:

  • defer_worker set: an inline cordless-worker-invoke policy granting lambda:InvokeFunction scoped to that one worker function’s ARN. It can invoke its own worker, nothing else.
  • ratelimit = true: an inline cordless-ratelimit-table policy granting dynamodb:GetItem and dynamodb:PutItem, scoped to that one rate-limit table’s ARN. No Scan, no DeleteTable, no access to any other table.

That’s the entire default footprint. The function can’t create or delete other AWS resources, can’t touch IAM, can’t read other Lambdas’ code, can’t see other DynamoDB tables. If your bot’s own code needs more (an S3 bucket for image caching, a table it manages, Stripe calls, whatever), that’s what policies in cordless.toml is for:

[deploy]
policies = [
"arn:aws:iam::aws:policy/AmazonS3FullAccess",
]

policies is entirely opt-in and entirely yours to control: cordless attaches whatever ARNs you list, verbatim, and grants nothing beyond them. If you don’t set it, the role never gets broader than the two cases above.

Separately, whatever AWS credentials you run cordless deploy/destroy with need permission to make the control-plane calls that provision (or tear down) all of this: creating the role, publishing the function, wiring the endpoint, and so on. This is standard “deploy tooling” access, the same shape as any IaC tool, not something granted to the bot itself:

  • IAM: CreateRole, GetRole, AttachRolePolicy, DetachRolePolicy, PutRolePolicy, DeleteRolePolicy, DeleteRole, ListAttachedRolePolicies, ListRolePolicies
  • Lambda: CreateFunction, GetFunctionConfiguration, UpdateFunctionCode, UpdateFunctionConfiguration, DeleteFunction, PublishLayerVersion, ListLayerVersions, DeleteLayerVersion, GetFunctionUrlConfig, CreateFunctionUrlConfig, AddPermission, RemovePermission
  • API Gateway v2: only touched if endpoint = "api_gateway": GetApis, CreateApi, CreateIntegration, CreateRoute, CreateStage, DeleteApi
  • EventBridge: only touched if you have @bot.cron() handlers or keep-warm = true: ListRules, PutRule, PutTargets, RemoveTargets, DeleteRule, ListTargetsByRule
  • DynamoDB: only touched if ratelimit = true: DescribeTable, CreateTable, UpdateTimeToLive, DeleteTable
  • CloudWatch Logs: DeleteLogGroup (destroy only, cleans up the function’s log group)
  • STS: GetCallerIdentity (validates your credentials and resolves your account id)

The AWS-managed AdministratorAccess or PowerUserAccess policies obviously cover all of this, but if you’d rather scope a dedicated deploy user/role down to exactly what’s listed above, that’s all deploy and destroy ever call, nothing here is undocumented or implicit.

[deploy]
bot = "lambda_function:bot" # explicit MODULE:ATTRIBUTE, skips auto-detection
setup = "db:create_tables" # run locally before deploying (e.g. provision a table)
function = "my-bot"
role_name = "my-bot-role" # default: "<function>-role"
handler = "lambda_function.handler"
region = "us-east-1" # closest to Discord's own API infra
runtime = "python3.12"
timeout = 10 # seconds (main Lambda)
memory = 256 # MB (default: 256)
architecture = "arm64" # "arm64" for a new function, unchanged for an existing one
endpoint = "function_url" # "function_url" (new functions default here) or "api_gateway"
layer_name = "cordless" # default: "cordless"
bundle_cordless = false # embed local cordless source instead of a layer
packages = ["pillow"] # extra pip packages to bundle
policies = [ # extra IAM policies attached to the role
"arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess",
]
ratelimit = true # provision shared rate-limit coordination (default: false)
keep-warm = true # ping the main function on a schedule so it doesn't go cold (default: false)
log_retention = 30 # days CloudWatch keeps function logs; 0 keeps AWS's never-expire default (default: 30)
# Deferred interactions
defer_worker = "my-bot-worker"
defer_handler = "lambda_function.worker_handler"
defer_timeout = 30
defer_memory = 256

Every key here has a matching CLI flag that takes precedence over it. See the CLI Reference for the full flag list, including upload and logs, which aren’t part of deploy itself.

Region is resolved in this order: --region flag → region in cordless.tomlAWS_DEFAULT_REGION env var. If none is set, deploy exits with an error. timeout and defer_timeout follow the same flag → toml → built-in-default order (no env var). Everything else with a CLI flag (handler, layer_name, role_name, runtime, defer_handler) follows flag → toml → built-in default too, just without an env var step. architecture and endpoint are the exception: their built-in default isn’t a fixed value, it’s “keep whatever an existing function already has, or use the better default (arm64, function_url) for a brand new one”, see below. function has no built-in default at all: it must come from --function or cordless.toml, or deploy exits with an error. memory and defer_memory have no CLI flag; they’re cordless.toml-only, defaulting to 256. keep-warm also has no CLI flag, cordless.toml-only, defaulting to off. log_retention is the same, cordless.toml-only, defaulting to 30.

bot (or, if omitted, auto-detection) is only needed for --register and to discover @bot.cron handlers. Deploying a bot with no cron schedules and without --register works without cordless ever importing your code.

Your Lambda runtime only has the standard library and cordless’s own dependencies: anything else your bot imports needs to be listed under packages in cordless.toml:

[deploy]
packages = ["pillow==11.2.0", "requests"]

deploy pip-installs these for the target runtime/architecture and bundles them into your function zip, caching the result between deploys so unchanged package lists don’t get reinstalled.

deploy also scans your bundled source for imports it can’t account for, anything that isn’t the standard library, a declared packages entry, or your own local code, and flags them as a Package check line in the summary at the end of the run. It’s a warning, not a failure: static scanning can’t see imports gated behind a try/except ImportError, so a false positive there is expected and safe to ignore. But if it names something you actually import unconditionally, that’s this exact mistake, add it to packages.

That cache is keyed on the exact packages list (plus runtime and architecture), not on time. An unpinned spec like requests resolves once, on whichever deploy first populates the cache, and every later deploy from that same machine reuses that exact install rather than re-resolving “latest.” So you won’t see a package silently change version between two deploys run back to back from your laptop. The gap is across machines: a fresh clone, a cleared ~/.cache/cordless, or a CI runner with no persisted cache re-resolves from scratch, which can land on a different version than whatever’s actually running in production if time has passed since the cache was last populated elsewhere. Pin anything you care about being reproducible everywhere with ==, like pillow above. Bumping the pin also changes the cache key, so it’s guaranteed to reinstall rather than reuse a stale wheel.

cordless reads .env automatically for all commands and merges its contents into your Lambda’s environment on deploy, so you only need to define credentials once:

DISCORD_PUBLIC_KEY=abc123...
DISCORD_BOT_TOKEN=your_bot_token

The --env KEY=VALUE flag takes precedence over .env if the same key appears in both.

For separate dev/staging/production configuration, add a .env.<name> file alongside .env:

.env # base values, e.g. dev credentials
.env.prod # only the keys that differ in prod

.env.prod only needs to contain the keys that differ, anything it doesn’t set still falls back to .env. Pick the environment with --environment/-E, --env, or $ENV, and every command that reads .env picks up the overlay the same way:

Terminal window
cordless deploy --env prod
ENV=prod cordless dev
cordless register --env prod

On deploy, --env stays dual-purpose: KEY=VALUE still sets a literal Lambda env var (repeatable), while a bare name like --env prod picks the .env.prod overlay instead:

Terminal window
cordless deploy --env prod --env DISCORD_PUBLIC_KEY=override

If the environment you name has no matching .env.<name> file, cordless silently falls back to .env alone rather than erroring, handy before you’ve created a .env.staging yet. .env.* files are never bundled into the deployment zip, same as .env.

Set setup (or pass --setup MODULE:FUNCTION) to run a no-argument function locally, with your own AWS credentials, before cordless touches Lambda. Useful for one-time provisioning your bot’s code assumes already exists, like creating a DynamoDB table:

db.py
import boto3
def create_tables():
boto3.client("dynamodb").create_table(
TableName="my-bot-scores",
KeySchema=[{"AttributeName": "user_id", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "user_id", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
[deploy]
setup = "db:create_tables"

It’s safe to make create_tables idempotent (e.g. catch ResourceInUseException) and leave setup configured permanently, it reruns on every deploy.

By default, deploy publishes cordless as a Lambda layer shared across deploys. Pass --bundle-cordless (or set bundle_cordless = true) to embed your local cordless installation directly in the function zip instead, useful when developing against an unreleased cordless change, since a layer would only ever reflect a published version.

endpoint controls how Discord actually reaches your function:

  • function_url (default for a new function): a direct Lambda Function URL. Fewer moving parts, lower latency, since there’s no separate service between Discord and your function. Trade-off: you can’t attach a custom domain to it directly.
  • api_gateway: routes through an API Gateway HTTP API in front of your function. Slightly more moving parts and a small extra network hop, but it’s what you need if you want a custom domain instead of the raw *.lambda-url.<region>.on.aws address.

cordless init asks this as a required interactive question the first time (or pass --endpoint to skip it). On deploy, an unset endpoint keeps whatever an existing function already has. AWS doesn’t let you switch a live function between the two in place, so this only picks a fresh default for a function that doesn’t exist yet. The same reasoning applies to architecture: AWS won’t change an existing function’s architecture either, so switching either one for real means deploying under a new function name and cutting over, not just flipping the config.

Some commands take longer than Discord’s 3-second limit. Mark them with defer=True:

@bot.command("slowthing", description="Takes a moment", defer=True)
async def slowthing(ctx):
await asyncio.sleep(5)
await ctx.send("Done!")

cordless responds immediately with a loading state, then invokes the worker Lambda in the background to send the real response. Works the same way on @bot.command(), @bot.button(), @bot.select(), @bot.modal(), and their Cog equivalents.

Set defer_worker in cordless.toml to enable this. Failed worker runs are never retried, so side effects (database writes, purchases) can’t silently run twice.

Add ephemeral=True to make the loading state and final reply visible only to the user who triggered the command:

@bot.command("secret", description="Just for you", defer=True, ephemeral=True)
async def secret(ctx):
await ctx.send("Only you can see this.")

Inside a deferred handler you can send more than one message. ctx.send() edits the original loading message; ctx.send_followup() posts a second, separate message:

@bot.command("report", description="Generate report", defer=True)
async def report(ctx):
await ctx.send("Report ready!")
await ctx.send_followup("Here's a second message.")

To delete the original loading message entirely:

await ctx.delete_original()

ratelimit = true provisions a DynamoDB table so send_message/edit_message/delete_message can coordinate across concurrent Lambda invocations, on top of the retry-on-429 behavior every outbound call already has by default. See the Rate Limiting reference for the full breakdown, including what does and doesn’t participate, and its current limitations.

Run code on a schedule with @bot.cron(): deploy wires each one to an EventBridge rule:

@bot.cron("rate(1 day)")
async def daily_rewards():
await bot.send_message(CHANNEL_ID, "Daily rewards are here!")

Schedules use EventBridge expressions (rate(...) or cron(...)). Handlers run on the worker Lambda when defer_worker is set, otherwise on the main function.

Test a cron handler locally without deploying:

Terminal window
cordless cron daily_rewards

@bot.cron() handlers all share one invoke target: the worker Lambda if defer_worker is set, otherwise the main function. That means if you have a defer_worker, there’s no way for a regular cron to ping the main function specifically, the one every Discord interaction hits first, and it can go cold between real invocations without you noticing.

keep-warm = true in cordless.toml solves this directly: it wires a dedicated EventBridge rule that pings the main function on a schedule (every 5 minutes by default; set keep-warm = "rate(10 minutes)" for a custom one), handled entirely inside cordless. You don’t write a handler for it yourself.

Push your slash commands to Discord after deploying:

Terminal window
# as part of deploy
cordless deploy --register
# or separately
cordless register
# guild-specific (instant, no propagation delay, good for development)
cordless register --guild-id 123456789

cordless auto-detects your Cordless() instance. Credentials are read from $DISCORD_BOT_TOKEN, or $DISCORD_CLIENT_ID + $DISCORD_CLIENT_SECRET.

Tail your function’s CloudWatch logs without leaving the terminal:

Terminal window
cordless logs --follow

After bumping the cordless version, cordless upload refreshes just the Lambda layer against an already-deployed function, without repackaging or redeploying your code:

Terminal window
cordless upload --function my-bot

See the CLI Reference for every flag on both.

Terminal window
cordless destroy

Deletes everything deploy created: the function(s), its endpoint (Function URL or API Gateway, whichever is in use), cron rules, the keep-warm rule (if any), CloudWatch log groups, and the IAM role. Asks for confirmation unless you pass --yes.

To also delete the cordless Lambda layer:

Terminal window
cordless destroy --layer