Deploying to AWS
Prerequisites
Section titled “Prerequisites”- AWS account with credentials configured (
aws configureor environment variables) cordless.tomlin your project root
What cordless deploy does
Section titled “What cordless deploy does”- Creates an IAM role with Lambda basic execution permissions (plus any
policiesyou list) - If
ratelimitis set, provisions a DynamoDB table for cross-invocation rate-limit coordination and scopes an IAM policy to it - Packages your source into a zip, bundling any extra
packagesyou specify (cached between deploys) - Publishes cordless as a Lambda layer, with PyNaCl included for fast signature verification (reused across deploys if unchanged)
- Creates or updates your Lambda function, and sets its CloudWatch log group’s retention to
log_retentiondays (default 30, otherwise logs never expire) - 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) - If
defer_workeris set, deploys a second worker Lambda (with the same log retention) and wires invoke permissions - If your bot has
@bot.cron()handlers, wires EventBridge schedule rules - If
keep-warmis set, wires an EventBridge rule that pings the main function directly on a schedule - With
--register, pushes your slash commands to Discord
IAM permissions
Section titled “IAM permissions”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.
What the deployed function can do
Section titled “What the deployed function can do”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_workerset: an inlinecordless-worker-invokepolicy grantinglambda:InvokeFunctionscoped to that one worker function’s ARN. It can invoke its own worker, nothing else.ratelimit = true: an inlinecordless-ratelimit-tablepolicy grantingdynamodb:GetItemanddynamodb:PutItem, scoped to that one rate-limit table’s ARN. NoScan, noDeleteTable, 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.
What your own credentials need
Section titled “What your own credentials need”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 orkeep-warm = true:ListRules,PutRule,PutTargets,RemoveTargets,DeleteRule,ListTargetsByRule - DynamoDB: only touched if
ratelimit = true:DescribeTable,CreateTable,UpdateTimeToLive,DeleteTable - CloudWatch Logs:
DeleteLogGroup(destroyonly, 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.
cordless.toml reference
Section titled “cordless.toml reference”[deploy]bot = "lambda_function:bot" # explicit MODULE:ATTRIBUTE, skips auto-detectionsetup = "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 infraruntime = "python3.12"timeout = 10 # seconds (main Lambda)memory = 256 # MB (default: 256)architecture = "arm64" # "arm64" for a new function, unchanged for an existing oneendpoint = "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 layerpackages = ["pillow"] # extra pip packages to bundlepolicies = [ # 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 interactionsdefer_worker = "my-bot-worker"defer_handler = "lambda_function.worker_handler"defer_timeout = 30defer_memory = 256Every 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.toml → AWS_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.
Extra packages
Section titled “Extra packages”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.
Environment variables
Section titled “Environment variables”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_tokenThe --env KEY=VALUE flag takes precedence over .env if the same key appears in both.
Per-environment files
Section titled “Per-environment files”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:
cordless deploy --env prodENV=prod cordless devcordless register --env prodOn 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:
cordless deploy --env prod --env DISCORD_PUBLIC_KEY=overrideIf 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.
Running setup before deploy
Section titled “Running setup before deploy”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:
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.
Bundling cordless instead of a layer
Section titled “Bundling cordless instead of a layer”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.
Choosing an endpoint
Section titled “Choosing an endpoint”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.awsaddress.
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.
Deferred interactions
Section titled “Deferred interactions”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.
Ephemeral deferred responses
Section titled “Ephemeral deferred responses”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.")Sending additional messages from a worker
Section titled “Sending additional messages from a worker”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()Rate limiting
Section titled “Rate limiting”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.
Scheduled handlers
Section titled “Scheduled handlers”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:
cordless cron daily_rewardsKeeping the main function warm
Section titled “Keeping the main function warm”@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.
Registering commands
Section titled “Registering commands”Push your slash commands to Discord after deploying:
# as part of deploycordless deploy --register
# or separatelycordless register
# guild-specific (instant, no propagation delay, good for development)cordless register --guild-id 123456789cordless auto-detects your Cordless() instance. Credentials are read from $DISCORD_BOT_TOKEN, or $DISCORD_CLIENT_ID + $DISCORD_CLIENT_SECRET.
Logs and layer updates
Section titled “Logs and layer updates”Tail your function’s CloudWatch logs without leaving the terminal:
cordless logs --followAfter bumping the cordless version, cordless upload refreshes just the Lambda layer against an already-deployed function, without repackaging or redeploying your code:
cordless upload --function my-botSee the CLI Reference for every flag on both.
Tearing down
Section titled “Tearing down”cordless destroyDeletes 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:
cordless destroy --layer