Compile Flask/FastAPI apps to AWS SAM serverless
Project description
deployless
deployless is a compiler that converts Flask applications (and in the future FastAPI) into AWS SAM templates ready to deploy as serverless Lambda functions. It does not require rewriting your app: simply add configuration annotations to your routes.py files and run deployless build.
Table of Contents
- What is deployless
- Installation
- deployless.yaml reference
- dpl.configure() in routes.py
- AWS Resources
- @dpl.cron() — Scheduled Lambdas
- @dpl.route() — Split Lambdas per route
- @dpl.lambda_function() — Standalone Lambdas
- Auto-detection of resources
- .env file and secrets
- Flask app initialization (init_app)
- CLI commands (
init,build,check,validate,deploy,clean,info,secrets) - Project structure
- Full example
What is deployless
deployless takes your Flask project structured by features and generates:
- A
template.yamlfor AWS SAM with one Lambda function per feature (and optionally one per specific route). - A
.dist/folder with the packaged code for each Lambda, including an auto-generatedbootstrap.pyand a mergedrequirements.txt. - CloudWatch Log Groups with configurable retention for each function.
Mental model
app/features/users/routes.py → UsersFunction (Lambda)
app/features/auth/routes.py → AuthFunction (Lambda)
app/features/tenant/routes.py → TenantFunction (Lambda)
Each feature lives in its own Lambda. If a specific endpoint needs a different configuration (more memory, longer timeout), you can "split" it into its own Lambda with @dpl.route().
Compilation flow
deployless build
│
├── 1. Reads deployless.yaml
├── 2. Discovers app/features/*/routes.py
├── 3. Imports each routes.py (extracts Blueprints and routes)
├── 4. Reads metadata from dpl.configure(), @dpl.cron(), @dpl.route()
├── 5. Validates (memory, timeout, duplicate routes, schedules, etc.)
├── 6. Generates .dist/{Feature}Function/ for each Lambda
└── 7. Writes template.yaml
Installation
pip install deployless
# Or with uv
uv add deployless
Runtime dependency in each Lambda
Each generated Lambda needs aws-wsgi to adapt Flask to the API Gateway event format. deployless adds it automatically to the requirements.txt of each .dist/ package — you do not need to install it manually.
Quick Start
# 1. Initialize a new project (interactive wizard + app scaffolding)
deployless init --app
# 2. Create your first feature
mkdir -p app/features/hello
cat > app/features/hello/routes.py << 'EOF'
from flask import Blueprint
hello_bp = Blueprint("hello", __name__)
@hello_bp.route("/hello")
def hello():
return {"message": "Hello from deployless!"}
EOF
# 3. Deploy
deployless deploy
deployless init asks for project name, stage, and runtime (with sensible defaults — just press Enter). The --app flag also creates the app structure (app/__init__.py, app/features/, app/shared/), run.py for local development, and a .gitignore.
Expected project structure
This is what your project should look like for deployless to work. deployless init --app generates the base structure — you only need to add your features.
my-project/
├── deployless.yaml # Project config (created by deployless init)
├── requirements.txt # Global dependencies
├── run.py # Local development: python run.py
├── .gitignore
├── app/
│ ├── __init__.py # App factory: create_app() → Flask
│ ├── features/ # One folder per feature = one Lambda per feature
│ │ ├── hello/
│ │ │ └── routes.py # REQUIRED — Flask Blueprint with routes
│ │ ├── users/
│ │ │ ├── routes.py # Blueprint + dpl.configure() + resources
│ │ │ ├── use_cases/ # Business logic (optional subdirectories)
│ │ │ ├── repositories/
│ │ │ ├── schemas/
│ │ │ └── requirements.txt # Optional — extra deps for this feature only
│ │ └── orders/
│ │ └── routes.py
│ └── shared/ # Shared code — copied into ALL Lambdas
│ ├── decorators/
│ ├── errors/
│ └── config.py
Key rules:
- Each feature must have a
routes.pywith at least one Flask Blueprint - Features cannot import from each other. Each feature is packaged into its own Lambda and has no access to other features' code. If two features need the same function, model, or utility, put it in
app/shared/ app/shared/is copied into every Lambda — use it for code shared across featuresapp/__init__.pywithcreate_app()is recommended — deployless uses it automatically for CORS, error handlers, middleware, etc.- Directories starting with
_(e.g.__pycache__) are ignored during discovery
deployless.yaml reference
Create this file at the project root (at the same level as requirements.txt). All fields are optional; default values are indicated.
# Project name
name: mi-app
# Cloud provider — only "aws" is supported for now
provider: aws
# Deployment stage. Can be overridden with --stage in the CLI.
# Used for: API Gateway StageName, APP_STAGE env var in all Lambdas,
# and samconfig.toml stack prefix.
# Example: deployless deploy --stage prod (overrides this value without editing the file)
stage: dev
# Tags applied to all CloudFormation resources
tags:
Project: mi-app
Environment: production
# Paths to the key directories of the project
paths:
features: app/features # Directory where features live
shared: app/shared # Shared code (copied into each Lambda)
# Global config for all Lambda functions
globals:
runtime: python3.13 # Lambda runtime
memory: 256 # MB (128–10240)
timeout: 30 # Seconds (1–900)
log_retention: 14 # Retention in CloudWatch (days)
# Valid values: 1,3,5,7,14,30,60,90,120,
# 150,180,365,400,545,731,1096,1827,3653
# API Gateway configuration
api:
endpoint_type: REGIONAL # REGIONAL | EDGE | PRIVATE
# CORS — only needed if you don't use create_app() in app/__init__.py.
# When create_app() is present, CORS is inherited automatically from it.
# This config is used as fallback when the app factory is unavailable.
cors:
allow_origin: "*" # Or a list: ["https://mi-app.com"]
allow_methods: [GET, POST, PUT, DELETE, OPTIONS]
allow_headers: [Content-Type, Authorization, X-API-Key]
max_age: 3600 # Seconds the browser caches the preflight
# allow_credentials: true # Not compatible with allow_origin: "*"
# Global API Gateway authentication
# (see "API Gateway Authentication" section for details)
auth:
type: cognito # cognito | lambda | iam
user_pool_arn: "arn:aws:cognito-idp:us-east-1:123456789:userpool/us-east-1_ABC"
name: CognitoAuthorizer # Optional
scopes: [] # Optional
# API Keys — simple form (single key)
api_keys: true # true = generate a new key | "key-id" = use existing
# API Keys — list form (multiple keys, see "API Keys and Rate Limiting" section)
api_keys:
- name: client-a # auto-generated key
plan: premium # references a usage_plans entry
- name: client-b
plan: free
- name: legacy-client
existing: "abc123keyid" # reference to an existing key in AWS
plan: free
- name: old-client
enabled: false # disabled (403), still tracked in CloudFormation
# Rate limiting — simple form (single plan, paired with api_keys: true/"key-id")
usage_plan:
rate: 10000 # Requests/second
burst: 2000 # Maximum peak
quota: 1000000 # Optional — total requests
period: DAY # DAY | WEEK | MONTH (required if quota is set)
# Rate limiting — named plans form (paired with api_keys list)
usage_plans:
premium:
rate: 10000
burst: 20000
quota: 1000000
period: MONTH
free:
rate: 100
burst: 200
quota: 10000
period: MONTH
# Custom domain (see "Custom Domain" section below for details)
domain:
domain_name: api.mi-app.com
base_path: /v1 # Optional
# MIME types that API Gateway treats as binary (non-UTF-8)
binary_media_types:
- image/png
- image/jpeg
- application/octet-stream
# Compress responses larger than N bytes
minimum_compression_size: 1024
# Global environment variables injected into ALL functions
env:
APP_ENV: production
LOG_LEVEL: INFO
# .env file — environment variables and secrets
# Normal variables are injected as env vars in all Lambdas.
# Variables with the SECRET_ prefix are pushed to SSM Parameter Store as String
# and injected as dynamic references {{resolve:ssm:...}}.
env_file: .env.production
# KMS key to encrypt secrets in SSM (optional).
# If not specified, SSM uses the AWS-managed key (aws/ssm).
# Accepts alias ("mi-app/secrets") or key ID / ARN.
secrets_kms: mi-app/secrets
# Flask app initialization hooks (fallback only).
# Used when create_app() is not found in app/__init__.py.
# If create_app() exists, it is called directly and init_app is ignored.
# Each entry is a dotted "module.function" path.
init_app:
- app.shared.errors.register_error_handlers
- app.shared.middleware.register_middleware
Environment variable interpolation
Use ${VAR_NAME} syntax inside string values of deployless.yaml to inject values from environment variables at build time. This keeps sensitive or environment-specific values (ARNs, domain names, keys) out of the YAML, so you can safely commit it to public repositories.
Syntax
| Syntax | Behavior |
|---|---|
${VAR} |
Replaced with the value of VAR. Error if not defined. |
${VAR:-default} |
Replaced with VAR if defined, otherwise uses default. |
Variables are resolved in string values only — integers, booleans, lists, and dict keys are never interpolated. A single string can contain multiple references: "https://${HOST}:${PORT}/v1".
Source
Variables are resolved exclusively from os.environ — the shell environment where deployless runs. The .env file configured via env_file: is not used for interpolation; that file is reserved for Lambda runtime environment variables and SSM secrets.
Local development
Set variables in your shell before running deployless:
# Option 1: export (persists in current shell session)
export API_DOMAIN=api.myapp.com
export COGNITO_POOL_ARN=arn:aws:cognito-idp:us-east-1:123456789:userpool/us-east-1_ABC
deployless deploy
# Option 2: inline (one-off, does not persist)
API_DOMAIN=api.myapp.com COGNITO_POOL_ARN=arn:aws:... deployless deploy
# Option 3: direnv (.envrc file, auto-loaded per directory)
# echo 'export API_DOMAIN=api.myapp.com' >> .envrc && direnv allow
GitHub Actions
Store sensitive values as repository secrets, then pass them via the env: block:
# deployless.yaml (committed to the repo — no secrets)
name: my-app
stage: ${DEPLOY_STAGE:-dev}
api:
domain:
domain_name: ${API_DOMAIN}
auth:
type: cognito
user_pool_arn: ${COGNITO_POOL_ARN}
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install deployless
- run: deployless deploy
env:
API_DOMAIN: ${{ secrets.API_DOMAIN }}
COGNITO_POOL_ARN: ${{ secrets.COGNITO_POOL_ARN }}
DEPLOY_STAGE: production
If any ${VAR} reference cannot be resolved (not in the environment and no default), the build fails with error E32 before any resources are created.
API Gateway Authentication - Soon
Cognito User Pool - Soon
api:
auth:
type: cognito
user_pool_arn: "arn:aws:cognito-idp:us-east-1:123456789:userpool/us-east-1_ABC"
name: CognitoAuthorizer # Optional, default: "CognitoAuthorizer"
scopes: # Optional — required OAuth2 scopes
- email
- profile
Lambda Authorizer (custom function) - Soon
api:
auth:
type: lambda
function_arn: "arn:aws:lambda:us-east-1:123456789:function:my-authorizer"
name: LambdaAuthorizer # Optional, default: "LambdaAuthorizer"
ttl: 300 # Seconds before re-authorizing (0 = no cache)
identity:
header: Authorization # Header where the token is located
IAM - Soon
api:
auth:
type: iam
Override auth per feature - Soon
From routes.py, you can override the global auth for an entire feature:
import deployless as dpl
# All endpoints in this feature are public (no auth)
dpl.configure(auth=None)
# All endpoints in this feature require an API key
dpl.configure(auth="api_key")
Override auth per individual route (split Lambda) - Soon
@dpl.route(memory=512, auth=None) # This endpoint is public
@bp.route('/health', methods=['GET'])
def health_check():
return {"status": "ok"}
@dpl.route(memory=1024, auth="api_key") # This endpoint requires an API key
@bp.route('/export', methods=['POST'])
def export_data():
...
Auth hierarchy (highest priority first) - Soon
@dpl.route(auth=...) ← Individual route (split lambdas only)
dpl.configure(auth=...) ← Entire feature
api.auth in deployless.yaml ← Global
API Keys and Rate Limiting
API keys are validated by API Gateway before Lambda is invoked — requests with a missing or invalid key return 403 Forbidden immediately and the Lambda is never called (no cold start, no invocation cost).
The key is sent by clients in the x-api-key header.
CORS compatibility: The
ApiKeyRequiredrestriction is applied per-endpoint, so SAM-generatedOPTIONSpreflight requests are never key-gated. Browsers can send preflight requests without an API key and receive the correct CORS headers — no special configuration is needed.
Simple form (single key)
api:
api_keys: true # Generates a new API key
usage_plan:
rate: 10000 # Requests/second (throttle)
burst: 2000 # Peak simultaneous requests
quota: 1000000 # Optional — total requests per period
period: DAY # DAY | WEEK | MONTH (required if quota is set)
To reference an existing key instead of generating a new one:
api:
api_keys: "abc123existingkeyid"
usage_plan:
rate: 10000
burst: 2000
The API Key ID appears in the CloudFormation stack Outputs:
# Retrieve the actual key value (not shown in Outputs for security)
aws apigateway get-api-key --api-key <ApiKeyId> --include-value
Multiple keys with named plans (list form)
Use the list form when you need multiple API keys — for example, a key per client or per tier.
api:
usage_plans:
premium:
rate: 10000
burst: 20000
quota: 1000000
period: MONTH
free:
rate: 100
burst: 200
quota: 10000
period: MONTH
api_keys:
- name: client-empresa # generates a new key, linked to premium plan
plan: premium
- name: client-startup
plan: free
- name: internal-tools # no plan — key exists but has no throttling
- name: legacy-client
existing: "abc123keyid" # references an existing key in AWS
plan: free
- name: old-client
enabled: false # disabled: returns 403, stays in CloudFormation
plan: free
Each generated key gets its own Output entry:
aws apigateway get-api-key --api-key <ApiKeyIdClientEmpresa> --include-value
aws apigateway get-api-key --api-key <ApiKeyIdClientStartup> --include-value
Key fields:
| Field | Type | Description |
|---|---|---|
name |
string | Logical name — used for CFN resource names and Outputs. Required unless existing is set. |
existing |
string | ID of an existing API key in AWS. No ApiKey CFN resource is created. |
plan |
string | References a key in usage_plans. If omitted, the key has no throttling. |
enabled |
bool | Default true. Set to false to disable the key (403) without deleting it from CFN. |
Key lifecycle
| Action | How |
|---|---|
| Create new key | Add entry with name: to the list |
| Disable key (revoke access, keep in CFN) | Set enabled: false |
| Rotate key | Add new entry + keep old with enabled: false during transition, then remove old entry |
| Delete key permanently | Remove the entry — CloudFormation deletes the AWS resource on next deploy |
| Use pre-existing key | Set existing: "key-id" instead of name: |
Auth override per feature or route
You can require (or skip) an API key at the feature or route level regardless of the global config:
# app/features/internal/routes.py
import deployless as dpl
# All routes in this feature require an API key
dpl.configure(auth="api_key")
# Or mark a single route as public even if the global default requires a key
@dpl.route(auth=None)
@bp.route("/health", methods=["GET"])
def health():
return {"status": "ok"}
Custom Domain
deployless can automatically provision an ACM certificate and configure a custom domain for your API Gateway. There are three ways to set it up:
Option 1: Route 53 (fully automatic)
If your DNS is managed by Route 53, deployless creates the ACM certificate and validates it automatically — zero manual steps.
# Auto-detect hosted zone
api:
domain:
domain_name: api.myapp.com
route53: true
# Or specify the hosted zone ID explicitly
api:
domain:
domain_name: api.myapp.com
route53:
hosted_zone_id: Z1234567890ABC
With route53: true, deployless uses boto3 to find the Route 53 hosted zone that matches your domain. If you have multiple hosted zones for the same domain, provide the hosted_zone_id explicitly.
Cost: ACM certificate is free. Route 53 hosted zone costs ~$0.50/month (if you already have it, there is no additional cost).
Option 2: External DNS (Cloudflare, GoDaddy, Namecheap, etc.)
If your DNS is managed outside AWS, deployless guides you through a step-by-step flow:
api:
domain:
domain_name: api.myapp.com
Deploy flow:
-
Run
deployless deploy— deployless requests an ACM certificate and shows the DNS validation records:[deployless] ACM certificate requested. Add these DNS records at your provider: CNAME _acme.api.myapp.com → xxx.acm-validations.aws Then run 'deployless deploy' again. -
Add the CNAME record at your DNS provider (e.g. Cloudflare).
-
Run
deployless deployagain — deployless verifies the certificate is validated, saves thecertificate_arntodeployless.yaml, and completes the deploy. -
After deploy, add the final CNAME to point your domain to API Gateway:
CNAME api.myapp.com → d-abc123.execute-api.us-east-1.amazonaws.com
Cost: $0 — ACM public certificates are free, and there is no additional charge for custom domains in API Gateway.
Option 3: Existing certificate (manual)
If you already have an ACM certificate, provide the ARN directly:
api:
domain:
domain_name: api.myapp.com
certificate_arn: "arn:aws:acm:us-east-1:123456789:certificate/abc-123"
base_path: /v1 # Optional
route53: # Optional — auto-configure DNS
hosted_zone_id: Z1234567890ABC
Note: EDGE endpoints require the ACM certificate to be in
us-east-1. REGIONAL endpoints require the certificate to be in the same region as the API Gateway.
Important: URL changes with custom domains
When using a custom domain, the stage prefix is removed from the URL. API Gateway's base path mapping handles the stage routing automatically:
# Without custom domain (stage prefix required)
https://xxx.execute-api.us-east-1.amazonaws.com/dev/todos
# With custom domain (no stage prefix)
https://api.myapp.com/todos
Update your frontend API base URL accordingly. If you use the wrong URL (e.g. /dev/todos on a custom domain), API Gateway returns "Missing Authentication Token" without CORS headers, causing preflight failures.
Validation rules
| Code | Rule |
|---|---|
| E00 | Resource validations: DynamoDB (key types, GSI, projection INCLUDE), S3 (bucket name DNS-compliant, 3–63 chars, no underscores), SQS (queue name, visibility_timeout, message_retention, max_receive_count), KMS (alias format, valid key_usage/key_spec, ECC/SIGN_VERIFY incompatibilities), SSMParameter (name starts with /, valid chars, valid type, non-empty value) |
| E01 | stage can only contain alphanumeric characters |
| E02 | api.endpoint_type must be REGIONAL, EDGE, or PRIVATE |
| E03 | globals.log_retention must be a valid CloudWatch value |
| E04 | allow_credentials: true is not compatible with allow_origin: "*" |
| E05 | Feature memory out of range (128–10240 MB) |
| E06 | Feature timeout out of range (1–900 s) |
| E07 | No routes found in feature's routes.py |
| E08 | Invalid cron schedule format |
| E09 | Cron memory out of range (128–10240 MB) |
| E10 | Cron timeout out of range (1–900 s) |
| E11 | api.auth.type must be cognito, lambda, or iam |
| E12 | api.auth (cognito): user_pool_arn is required |
| E13 | api.auth (lambda): function_arn is required |
| E14 | api.usage_plan: rate and burst are required |
| E15 | api.usage_plan: period is required if quota is set |
| E16 | api.usage_plan.period must be DAY, WEEK, or MONTH |
| E17 | api.domain: domain_name is required |
| E18 | api.minimum_compression_size must be an integer >= 0 |
| E19 | ephemeral_storage out of range (512–10240 MB) |
| E20 | reserved_concurrency must be >= 0 |
| E21 | provisioned_concurrency must be >= 1 |
| E22 | log_retention per feature must be a valid CloudWatch value |
| E23 | alarms.sns_topic_arn must be a valid ARN (starts with arn:) |
| E24 | alarms.duration.threshold_pct must be between 1 and 100 |
| E25 | lambda_function memory out of range (128–10240 MB) |
| E26 | lambda_function timeout out of range (1–900 s) |
| E27 | Specified env_file does not exist |
| E28 | SECRET_ variable with empty value |
| E29 | Invalid secrets_kms format |
| E30 | Invalid resource permission level (must be one of the valid levels for that resource type) |
| E31 | init_app entry is not a string or is not in module.function format |
| E32 | Unresolved environment variable in deployless.yaml — referenced ${VAR} is not defined and has no default |
| E33 | Duplicate route: same HTTP method + path defined in two different features |
| E34 | api.usage_plans defined without api.api_keys |
| E35 | A plan in api.usage_plans is missing rate/burst, or has an invalid period |
| E36 | An entry in api.api_keys list is missing both name and existing |
| E37 | An entry in api.api_keys list references a plan not defined in api.usage_plans |
| W01 | Replacement-causing change detected — a resource property that is immutable after creation was modified. Applies to: DynamoDB (key schema, table name), S3 (bucket name), SQS (queue name, FIFO flag), KMS (key usage, key spec), SSM (parameter name, type). CloudFormation cannot replace custom-named resources in-place; the build is halted before deploy. |
dpl.configure() in routes.py
dpl.configure() is called at module level in routes.py to configure the AWS Lambda function that deployless generates for that feature. Every parameter you pass here maps directly to a property on the AWS::Serverless::Function resource in the generated template.yaml (memory → MemorySize, timeout → Timeout, architectures → Architectures, etc.).
It is a no-op at runtime: when your Flask app starts normally, this call does nothing visible. Only the deployless compiler reads it.
deployless automatically detects which feature is being compiled using a context variable set by the compiler.
Full parameter reference
import deployless as dpl
dpl.configure(
# ── Basic ───────────────────────────────────────────────────────────────
memory=512, # int — MB. Overrides globals.memory (128–10240)
timeout=30, # int — Seconds. Overrides globals.timeout (1–900)
description="Mi feature", # str — Description visible in CloudFormation
# ── Environment ──────────────────────────────────────────────────────────
env={"FLAG": "true"}, # dict — Additional env vars for this Lambda
layers=["arn:aws:lambda:..."],# list — Lambda Layer ARNs
# ── IAM ──────────────────────────────────────────────────────────────────
policies=[ # list — Inline IAM policies (SAM format)
"AmazonDynamoDBReadOnlyAccess", # Managed policy by name
{"DynamoDBCrudPolicy": {"TableName": dpl.Ref(mi_tabla)}}, # SAM policy
{"Version": "2012-10-17", "Statement": [...]}, # Inline policy
],
# ── AWS Resources (permission overrides only) ──────────────────────────────
# Resources are auto-detected (see "Auto-detection of resources" section).
# Use resources= ONLY to override the default "crud" permission level:
resources={
"uploads_bucket": (uploads_bucket, "read"), # restrict to read-only
"jobs_queue": (jobs_queue, "send"), # restrict to send-only
},
# deployless auto-generates IAM policies from each detected resource.
# Use policies= only for additional or non-standard permissions.
# policies=[...]
# ── Architecture ──────────────────────────────────────────────────────────
architectures=["arm64"], # list — ["x86_64"] or ["arm64"] (Graviton, ~20% cheaper)
tracing=True, # bool — Enables AWS X-Ray distributed tracing
# ── Concurrency ───────────────────────────────────────────────────────────
reserved_concurrency=10, # int >= 0 — Maximum simultaneous execution limit.
# 0 = full throttle (useful for temporarily disabling)
provisioned_concurrency=3, # int >= 1 — Pre-warmed instances (eliminates cold starts).
# Implies AutoPublishAlias: live in the template.
# ── Temporary storage ─────────────────────────────────────────────────────
ephemeral_storage=1024, # int — Size of /tmp in MB (512–10240, default 512)
# ── Reliability ───────────────────────────────────────────────────────────
dlq=True, # bool — Creates an SQS Dead Letter Queue for
# failed asynchronous invocations
# ── Observability ─────────────────────────────────────────────────────────
log_retention=30, # int — Retention days in CloudWatch (overrides global)
alarms=True, # Enables CloudWatch Alarms with default thresholds
# alarms=False, # Disables alarms for this feature
# alarms={...}, # Custom config (see Alarms section)
# ── Auth (API Gateway) ────────────────────────────────────────────────────
auth=None, # None = public routes | "api_key" = requires API key
# (not specified = inherits global auth from deployless.yaml)
)
Full example
# app/features/user/routes.py
from flask import Blueprint
import deployless as dpl
# Resource auto-detected → DynamoDBCrudPolicy auto-generated, USERS_TABLE env var injected
users_table = dpl.DynamoDB(
"users-table",
pk="tenant_id",
sk="user_id",
gsi=[{"name": "EmailIndex", "pk": "email"}],
ttl_attribute="expires_at",
deletion_policy="Retain",
)
dpl.configure(
memory=512,
timeout=30,
description="User Management API",
# No need to declare resources= here — users_table is auto-detected with "crud"
architectures=["arm64"],
dlq=True,
alarms=True,
log_retention=30,
)
user_bp = Blueprint("user_bp", __name__, url_prefix="/users")
@user_bp.route("", methods=["GET"])
def list_users():
...
AWS Resources
Resources are auto-detected by the compiler. Any Resource instance (DynamoDB, S3, SQS, KMS, SSMParameter) is automatically registered with "crud" permissions — deployless adds them to template.yaml, auto-generates IAM policies, and injects environment variables.
Detection rules:
| Resource location | Detection | What you need to do |
|---|---|---|
Inside features/auth/ (any .py file) |
Automatic | Nothing — detected by scanning the feature directory |
In app/shared/ |
Only if imported | You must import the resource in your routes.py for the Lambda to get permissions |
# app/shared/resources.py
import deployless as dpl
shared_table = dpl.DynamoDB("shared-table", pk="id")
# app/features/auth/routes.py
from app.shared.resources import shared_table # ← this import triggers auto-detection
# Without this import, the auth Lambda will NOT have permissions for shared_table
See the Auto-detection of resources section for the full details.
DynamoDB
dpl.DynamoDB(
table_name: str, # Table name in AWS
pk: str = "id", # Partition key
pk_type: str = "S", # "S" (String) | "N" (Number) | "B" (Binary)
sk: str = None, # Optional sort key. If defined → AWS::DynamoDB::Table
sk_type: str = "S", # "S" | "N" | "B"
gsi: list = None, # Global Secondary Indexes (see format below)
billing_mode: str = "PAY_PER_REQUEST",# "PAY_PER_REQUEST" | "PROVISIONED"
read_capacity: int = None, # Only for billing_mode="PROVISIONED" (default: 5)
write_capacity: int = None, # Only for billing_mode="PROVISIONED" (default: 5)
ttl_attribute: str = None, # Time-To-Live attribute (DynamoDB expires it automatically)
stream: str = None, # "NEW_IMAGE" | "OLD_IMAGE" | "NEW_AND_OLD_IMAGES" | "KEYS_ONLY"
point_in_time_recovery: bool = False, # Enables PITR (point-in-time recovery)
sse_enabled: bool = True, # Encryption at rest with AWS-managed KMS
deletion_policy: str = "Delete", # "Delete" | "Retain" | "Snapshot"
existing: bool = False, # True = table already exists, do not create (only injects env var)
)
CloudFormation type
deployless always generates AWS::DynamoDB::Table regardless of whether a sort key or GSI is defined. This avoids CloudFormation replacement (and data loss) when you later add a sort key or GSI to a table that started with only a partition key.
Auto-generated environment variable
The -table / _table suffix is removed to avoid redundancy:
table_name |
Environment variable |
|---|---|
users-table |
USERS_TABLE |
orders_table |
ORDERS_TABLE |
sessions |
SESSIONS_TABLE |
GSI format
Each element of the gsi list accepts:
{
"name": "StatusIndex", # Required — index name
"pk": "status", # Required — index partition key
"pk_type": "S", # Optional, default "S"
"sk": "created_at", # Optional — index sort key
"sk_type": "S", # Optional, default "S"
"projection": "ALL", # "ALL" | "KEYS_ONLY" | "INCLUDE" (default "ALL")
"non_key_attributes": ["email"], # Required only if projection="INCLUDE"
}
Examples
Simple table (PK only):
dpl.DynamoDB("sessions-table", pk="session_id", ttl_attribute="expires_at")
# → AWS::DynamoDB::Table
# → Variable: SESSIONS_TABLE
Table with SK and multiple GSIs:
dpl.DynamoDB(
"orders-table",
pk="tenant_id",
sk="order_id",
gsi=[
{
"name": "StatusIndex",
"pk": "status",
"sk": "created_at",
},
{
"name": "CustomerIndex",
"pk": "customer_id",
"projection": "INCLUDE",
"non_key_attributes": ["total", "status"],
},
],
ttl_attribute="expires_at",
point_in_time_recovery=True,
deletion_policy="Retain",
)
# → AWS::DynamoDB::Table with SSEEnabled=True
# → Variable: ORDERS_TABLE
Table with provisioned capacity:
dpl.DynamoDB(
"high-traffic-table",
pk="pk",
sk="sk",
billing_mode="PROVISIONED",
read_capacity=100,
write_capacity=50,
)
Table with DynamoDB Streams:
dpl.DynamoDB(
"events-table",
pk="event_id",
stream="NEW_AND_OLD_IMAGES", # Triggers a Lambda on every change
)
Existing table (do not create, only inject env var):
dpl.DynamoDB("prod-users-table", existing=True)
# Does not generate a CloudFormation resource
# Injects: PROD_USERS_TABLE = "prod-users-table" (literal string)
Permission levels
| Level | Auto-generated SAM policy |
|---|---|
"crud" (default) |
DynamoDBCrudPolicy |
"read" |
DynamoDBReadPolicy |
"write" |
DynamoDBWritePolicy |
# Auto-detected resources get "crud" by default.
# Use resources={} only to restrict:
dpl.configure(
resources={
"catalog": (dpl.DynamoDB("catalog-table"), "read"), # restrict to read-only
},
)
S3 - Soon
dpl.S3(
bucket_name: str,
versioning: bool = False,
encryption: bool = True, # SSE-S3 (AES256) enabled by default
cors: list = None, # List of CORS rules (CloudFormation CorsRule format)
lifecycle_rules: list = None, # List of lifecycle rules (CloudFormation format)
public_access_block: bool = True, # Blocks public access by default
deletion_policy: str = "Delete",
existing: bool = False,
)
Auto-generated environment variable:
uploads-bucket→UPLOADS_BUCKETmy_files_bucket→MY_FILES_BUCKET(the-bucket/_bucketsuffix is removed)
Compile-time validations (E00):
bucket_namecannot be empty- Length between 3 and 63 characters
- Cannot contain underscores (S3 is DNS-compliant)
- Lowercase only, digits, hyphens, and dots — starts and ends with alphanumeric
Basic example:
dpl.S3("user-uploads")
# → SSE-S3 AES256 enabled, public access blocked
# → Variable: UPLOADS_BUCKET
Permission levels:
| Level | Auto-generated SAM policy |
|---|---|
"crud" (default) |
S3CrudPolicy |
"read" |
S3ReadPolicy |
"write" |
S3WritePolicy |
# Auto-detected resources get "crud" by default.
# Use resources={} only to restrict:
dpl.configure(
resources={
"assets": (dpl.S3("static-assets"), "read"), # restrict to read-only
},
)
Example with all options:
dpl.S3(
"user-uploads",
versioning=True,
encryption=True, # AES256 by default — pass False only if using external KMS
public_access_block=True,
deletion_policy="Retain",
cors=[
{
"AllowedOrigins": ["https://mi-app.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"MaxAge": 3600,
}
],
lifecycle_rules=[
{
"Id": "expire-tmp",
"Status": "Enabled",
"ExpirationInDays": 7,
"Prefix": "tmp/",
}
],
)
SQS - Soon
dpl.SQS(
queue_name: str,
fifo: bool = False, # True = FIFO queue. Adds .fifo to the name automatically.
dlq: bool = False, # True = also creates a Dead Letter Queue
visibility_timeout: int = 30, # seconds (0–43200)
message_retention: int = 345600, # seconds (60–1209600, default 4 days)
max_receive_count: int = 3, # Attempts before sending to DLQ (1–1000)
encryption: bool = True, # SqsManagedSseEnabled — SSE-SQS enabled by default
deletion_policy: str = "Delete",
existing: bool = False,
)
Note: SQS and KMS return multiple CloudFormation resources (the main queue + DLQ, or the key + alias). deployless inserts them all correctly into the template.
Auto-generated environment variable:
notifications-queue→NOTIFICATIONS_QUEUE_URL
Compile-time validations (E00):
queue_namecannot be empty or exceed 80 characters- Alphanumeric only,
-and_(the.fifosuffix is excluded from validation) visibility_timeoutmust be in range[0, 43200]message_retentionmust be in range[60, 1209600]max_receive_countmust be in range[1, 1000]
Basic example:
dpl.SQS("email-notifications")
# → SSE-SQS enabled, 4-day retention, 30s visibility
# → Variable: EMAIL_NOTIFICATIONS_QUEUE_URL
Example with DLQ:
dpl.SQS(
"email-notifications",
dlq=True,
visibility_timeout=60,
message_retention=86400, # 1 day
max_receive_count=5,
)
# → Main queue + DLQ with 14-day retention
# → Both with SSE-SQS enabled
FIFO example:
dpl.SQS(
"orders",
fifo=True, # → queue_name becomes "orders.fifo" automatically
dlq=True, # → DLQ will also be FIFO: "orders-dlq.fifo"
)
Permission levels:
| Level | Auto-generated SAM policies |
|---|---|
"crud" (default) |
SQSSendMessagePolicy + SQSPollerPolicy |
"send" |
SQSSendMessagePolicy |
"poll" |
SQSPollerPolicy |
# Auto-detected resources get "crud" by default.
# Use resources={} only to restrict:
dpl.configure(
resources={
"tasks": (dpl.SQS("tasks-queue"), "send"), # restrict to send-only
},
)
KMS
dpl.KMS(
alias: str = None, # e.g. "alias/mi-app" or simply "mi-app"
description: str = None,
key_usage: str = "ENCRYPT_DECRYPT", # "ENCRYPT_DECRYPT" | "SIGN_VERIFY" | "GENERATE_VERIFY_MAC"
key_spec: str = "SYMMETRIC_DEFAULT", # "SYMMETRIC_DEFAULT" | "RSA_2048/3072/4096"
# | "ECC_NIST_P256/P384/P521" | "ECC_SECG_P256K1"
# | "HMAC_224/256/384/512"
enable_rotation: bool = None, # None → auto: True for SYMMETRIC_DEFAULT, False otherwise
deletion_policy: str = "Retain", # KMS uses Retain by default (security)
existing_key_id: str = None, # ID or ARN of an existing key (does not create resource)
env_var: str = None, # Forces the name of the generated env var
)
Auto-generated environment variable:
env_var="MY_KEY"→MY_KEY(takes priority over any automatic derivation)alias="myapp/encryption"→MYAPP_ENCRYPTION_KEY_ID- No alias or env_var →
KMS_KEY_ID
Generated CloudFormation resources:
AWS::KMS::Key— withEnabled: True,KeyUsage,KeySpec, and a basic key policy (root account)AWS::KMS::Alias— optional alias to identify the key by nameEnableKeyRotationis only added whenkey_spec="SYMMETRIC_DEFAULT"(asymmetric keys do not support automatic rotation)
Compile-time validations (E00):
aliascan only contain alphanumeric characters,-,_,/key_usagemust be one of the valid valueskey_specmust be one of the valid valuesenable_rotation=Trueis not valid for asymmetric keys (RSA, ECC, HMAC)- ECC
key_specis not compatible withkey_usage="ENCRYPT_DECRYPT" key_spec="SYMMETRIC_DEFAULT"is not compatible withkey_usage="SIGN_VERIFY"
Auto-detected permissions: When a dpl.KMS() is created inside a feature directory, deployless auto-detects it and generates IAM policies with "crud" permissions (kms:Encrypt, kms:Decrypt, kms:GenerateDataKey, kms:DescribeKey). No need to declare it in resources={} or write manual policies=[].
Basic example (auto-detected)
# app/features/tenant/routes.py
import deployless as dpl
kms_key = dpl.KMS(
alias="mi-app/datos",
description="Encryption key for sensitive data",
enable_rotation=True,
deletion_policy="Retain",
)
# No need to declare resources= or policies= — kms_key is auto-detected
# with "crud" permissions (Encrypt, Decrypt, GenerateDataKey, DescribeKey)
dpl.configure(description="Tenant Service")
Restricting permissions
Use resources={} only when you need to restrict the default "crud" level:
# This feature only needs to decrypt — restrict from the default "crud"
dpl.configure(
resources={"datos_key": (kms_key, "decrypt")},
)
How to use the key in app code
The KMS_KEY_ID environment variable (or {ALIAS}_KEY_ID if using an alias) is automatically injected into the Lambda. Use it in your encryption services:
# app/features/tenant/services/kms_service.py
import boto3
import base64
import os
from botocore.exceptions import ClientError
kms_client = boto3.client('kms')
def encrypt_with_kms(plaintext: str) -> str:
"""Encrypts a string and returns the ciphertext in base64."""
response = kms_client.encrypt(
KeyId=os.getenv('KMS_KEY_ID'),
Plaintext=plaintext.encode('utf-8'),
)
return base64.b64encode(response['CiphertextBlob']).decode('utf-8')
def decrypt_with_kms(ciphertext_b64: str) -> str:
"""Decrypts a base64 ciphertext and returns the plaintext."""
ciphertext_blob = base64.b64decode(ciphertext_b64)
response = kms_client.decrypt(CiphertextBlob=ciphertext_blob)
return response['Plaintext'].decode('utf-8')
kms:Decryptdoes not need to specifyKeyIdbecause the ciphertext already embeds the ID of the key that encrypted it.
Full example — RSA key encryption per tenant
A real pattern: the tenant feature encrypts the RSA private key when creating the tenant, and the auth feature decrypts it on each login.
# app/features/tenant/routes.py
import deployless as dpl
tenant_key = dpl.KMS(
alias="ums/tenant-keys",
description="Encryption of RSA private keys per tenant",
enable_rotation=True,
deletion_policy="Retain",
)
tenants_table = dpl.DynamoDB("ums-tenants", pk="tenant_id", deletion_policy="Retain")
# Both resources are auto-detected with "crud" permissions.
# We restrict tenant_key to "encrypt" only (this feature doesn't need decrypt).
dpl.configure(
resources={"tenant_key": (tenant_key, "encrypt")},
)
# app/features/auth/routes.py
import deployless as dpl
# Import the shared KMS key — auto-detected with "crud" by default.
# We restrict to "decrypt" only (this feature doesn't need encrypt).
from app.shared.services.kms_service import tenant_key
dpl.configure(
resources={"tenant_key": (tenant_key, "decrypt")},
)
Auto-injected environment variables:
| Alias | Variable |
|---|---|
ums/tenant-keys |
UMS_TENANT_KEYS_KEY_ID |
mi-app |
MI_APP_KEY_ID |
| No alias | KMS_KEY_ID |
Asymmetric key for digital signing (RSA)
signing_key = dpl.KMS(
alias="mi-app/signing",
description="RSA key for signing JWTs or documents",
key_usage="SIGN_VERIFY",
key_spec="RSA_2048",
# enable_rotation does not apply — automatically ignored for asymmetric keys
)
# Auto-detected with "crud" permissions.
# For SIGN_VERIFY keys, you may need additional actions (kms:Sign, kms:Verify,
# kms:GetPublicKey) that are not covered by the default "crud" level.
# Add them via policies= when needed:
dpl.configure(
policies=[
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["kms:Sign", "kms:Verify", "kms:GetPublicKey"],
"Resource": dpl.Ref(signing_key),
}
],
}
],
)
Existing key (do not create, only inject env var)
dpl.KMS(existing_key_id="arn:aws:kms:us-east-1:123456789:key/abc-123")
# Does not generate a CloudFormation resource
# KMS_KEY_ID = "arn:aws:kms:us-east-1:123456789:key/abc-123"
Permission levels
| Level | IAM actions granted |
|---|---|
"crud" (default) |
kms:Encrypt, kms:Decrypt, kms:GenerateDataKey, kms:DescribeKey |
"encrypt" |
kms:Encrypt |
"decrypt" |
kms:Decrypt |
# Auto-detected resources get "crud" by default.
# Use resources={} only to restrict:
dpl.configure(
resources={
"read_key": (dpl.KMS(alias="mi-app/read"), "decrypt"), # restrict to decrypt only
},
)
SSM Parameter Store
deployless provides two tools for SSM: dpl.SSMParameter to create a parameter as a CloudFormation resource, and dpl.SSMParam to reference an existing parameter as a dynamic reference in env vars.
dpl.SSMParameter — create a parameter
dpl.SSMParameter(
name: str, # Parameter path, must start with "/"
value: str, # Parameter value
type: str = "String", # "String" | "StringList" | "SecureString"
description: str = None,
existing: bool = False, # True = do not create, only inject env var
)
Auto-generated environment variable — last segment of the path:
/myapp/db/host→HOST/myapp/api/secret-key→SECRET_KEY
Compile-time validations (E00):
namemust start with/- Alphanumeric only,
.,-,_,/ typemust beString,StringList, orSecureStringvaluecannot be empty (except forSecureString)
Example:
db_host = dpl.SSMParameter(
"/myapp/db/host",
value="db.example.com",
description="RDS endpoint",
)
# Auto-detected — SSMParameterReadPolicy is auto-generated
dpl.configure(description="My Service")
# → Variable: HOST = {"Ref": "MyappDbHostParameter"}
dpl.SSMParam — reference an existing parameter
Does not generate a CloudFormation resource. Produces a CloudFormation dynamic reference directly in the env var value.
dpl.SSMParam(
name: str, # Path of the existing parameter
secure: bool = False, # True → "{{resolve:ssm-secure:/path}}" (SecureString)
version: int = None, # Optional — pin to a specific version
)
Usage in env vars:
dpl.configure(
env={
"DB_HOST": dpl.SSMParam("/prod/db/host"),
"API_KEY": dpl.SSMParam("/prod/api/key", secure=True),
"DB_PASS": dpl.SSMParam("/prod/db/password", secure=True, version=3),
}
)
This generates in the template:
Environment:
Variables:
DB_HOST: "{{resolve:ssm:/prod/db/host}}"
API_KEY: "{{resolve:ssm-secure:/prod/api/key}}"
DB_PASS: "{{resolve:ssm-secure:/prod/db/password:3}}"
{{resolve:ssm-secure:...}}only works withSecureStringparameters and requires the Lambda to havessm:GetParameter+kms:Decryptpermission on the parameter's KMS key.
Permission levels (SSMParameter only)
| Level | Auto-generated SAM policy / IAM action |
|---|---|
"crud" / "read" (default) |
SSMParameterReadPolicy |
"write" |
Inline ssm:PutParameter |
# Auto-detected resources get "crud"/"read" by default.
# Use resources={} only to change to "write":
dpl.configure(
resources={
"counter": (dpl.SSMParameter("/app/counter", value="0"), "write"), # write access
},
)
CloudWatch Alarms - Soon
deployless can automatically generate 3 alarms per Lambda: errors, throttles, and duration.
Activation
# In routes.py — enables alarms with default thresholds
dpl.configure(alarms=True)
# With custom thresholds
dpl.configure(alarms={
"errors": {
"threshold": 1, # Trigger when Errors >= 1 in the period
"period": 300, # Evaluation period in seconds
},
"throttles": {
"threshold": 1,
"period": 300,
},
"duration": {
"threshold_pct": 80, # Trigger when Duration > 80% of the configured timeout
"period": 300, # (if timeout=30s → alarm at 24000ms)
},
"sns_topic_arn": "arn:aws:sns:us-east-1:123456789:my-alerts", # Optional
})
# Disable alarms for this feature even if globally active
dpl.configure(alarms=False)
Global alarms (for all features)
In deployless.yaml, you can activate alarms for the entire project:
alarms:
errors:
threshold: 1
period: 300
throttles:
threshold: 1
period: 300
duration:
threshold_pct: 80
period: 300
sns_topic_arn: "arn:aws:sns:us-east-1:123456789:my-alerts"
Generated resources
For each feature with alarms active, deployless generates in the template:
UserFunctionErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
MetricName: Errors
Namespace: AWS/Lambda
Statistic: Sum
Period: 300
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: notBreaching
UserFunctionThrottlesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
MetricName: Throttles
# ...
UserFunctionDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
MetricName: Duration
Statistic: Maximum
Threshold: 24000 # 80% of 30s = 24000ms
# ...
dpl.Ref() and dpl.GetAtt() — Referencing resources
Use dpl.Ref(resource) to get the logical ID of a resource (generates {"Ref": "LogicalId"}), and dpl.GetAtt(resource, attr) to get a specific attribute (generates {"Fn::GetAtt": ["LogicalId", "Attr"]}).
They are typically used in policies= for non-standard permissions that deployless cannot auto-generate (e.g. dynamodb:Query on a specific index ARN):
tabla = dpl.DynamoDB("users-table")
bucket = dpl.S3("uploads")
dpl.configure(
resources={"users": tabla, "uploads": bucket}, # standard policies auto-generated
policies=[
# Additional non-standard policy: access a specific index ARN
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:Query"],
"Resource": {"Fn::Sub": "${%s.Arn}/index/EmailIndex" % dpl.Ref(tabla)},
},
]
}
],
)
dpl.Ref() and dpl.GetAtt() accept both a resource object and a string with the CloudFormation logical ID.
@dpl.cron() — Scheduled Lambdas
Decorate any function with @dpl.cron() to have deployless deploy it as a separate Lambda triggered by EventBridge (CloudWatch Events) on the indicated schedule.
@dpl.cron(
schedule: str, # Schedule expression (required)
memory: int = None, # MB. If None, uses globals.memory
timeout: int = None, # Seconds. If None, uses globals.timeout
env: dict = None, # Additional environment variables
description: str = None,
)
Schedule formats:
"rate(5 minutes)"— every 5 minutes"rate(1 hour)"— every hour"rate(24 hours)"— daily"cron(0 9 * * ? *)"— every day at 9:00 UTC
The function must have the Lambda signature (event, context).
Example:
# app/features/user/routes.py
import deployless as dpl
@dpl.cron(
schedule="rate(24 hours)",
memory=128,
timeout=300,
description="Daily cleanup of expired users",
)
def cleanup_expired_users(event, context):
# Your logic here
deleted = delete_expired_users()
return {"status": "ok", "deleted": deleted}
This generates in template.yaml:
CleanupExpiredUsersFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: .dist/CleanupExpiredUsersFunction/
Handler: bootstrap.handler
MemorySize: 128
Timeout: 300
Description: Limpieza diaria de usuarios expirados
Events:
Schedule:
Type: Schedule
Properties:
Schedule: rate(24 hours)
@dpl.route() — Split Lambdas per route - Soon
By default, all routes in a feature share a single Lambda. With @dpl.route() you can isolate a specific endpoint into its own Lambda (useful for endpoints that consume many resources or have different timeouts).
@dpl.route(
memory: int = None,
timeout: int = None,
description: str = None,
auth = <not specified>, # None = public | "api_key" = requires API key
# (not specified = inherits auth from feature or global)
)
The @dpl.route() decorator must go above the Flask decorator.
# app/features/user/routes.py
import deployless as dpl
from flask import Blueprint
user_bp = Blueprint("user_bp", __name__, url_prefix="/users")
@dpl.route(memory=1024, timeout=120, description="Heavy data export")
@user_bp.route("/export", methods=["POST"])
def export_users():
# This endpoint will have its own Lambda with 1 GB and 2-minute timeout
...
@user_bp.route("", methods=["GET"])
def list_users():
# This endpoint goes in the feature's shared Lambda
...
This generates two separate Lambda functions:
UserFunction— containsGET /users(and all other endpoints without@dpl.route())ExportUsersFunction— contains onlyPOST /users/export
@dpl.lambda_function() — Standalone Lambdas - Soon
For Lambda functions that have no HTTP routes or schedules — for example, SQS consumers, S3 event handlers, or Step Functions steps — use @dpl.lambda_function().
@dpl.lambda_function(
memory: int = None, # MB. If None, uses globals.memory
timeout: int = None, # Seconds. If None, uses globals.timeout
env: dict = None, # Additional environment variables
description: str = None,
)
The function must have the Lambda signature (event, context).
Example:
# app/features/orders/routes.py
import deployless as dpl
@dpl.lambda_function(memory=512, timeout=60, description="Processes messages from the orders queue")
def process_order_queue(event, context):
for record in event.get("Records", []):
body = record["body"]
print(f"Procesando pedido: {body}")
return {"processed": len(event.get("Records", []))}
This generates in template.yaml:
ProcessOrderQueueFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: .dist/ProcessOrderQueueFunction/
Handler: bootstrap.handler
MemorySize: 512
Timeout: 60
Description: Procesa mensajes de la cola de pedidos
Note: Unlike HTTP features, standalone lambdas have no API Gateway events. You can connect them to SQS, S3, DynamoDB Streams, etc. manually in the template or via event source mappings.
Auto-detection of resources
deployless automatically detects Resource instances (DynamoDB, S3, SQS, KMS, SSMParameter) without requiring explicit declaration in dpl.configure(resources={...}). There are two detection mechanisms:
1. Feature-local resources
Any Resource created inside a feature's directory (in any .py file, not just routes.py) is auto-detected for that feature's Lambda with "crud" permissions.
# app/features/auth/services.py
import deployless as dpl
auth_table = dpl.DynamoDB("auth-table", pk="PK", sk="SK")
# ↑ Auto-detected for the auth Lambda — no need to import in routes.py
2. Shared resources (imported from shared/)
Resources defined in app/shared/ are only injected into features that explicitly import them in routes.py (least privilege).
# app/shared/services/dynamo.py
import deployless as dpl
ums_table = dpl.DynamoDB("ums-table", pk="PK", sk="SK")
# app/features/auth/routes.py
from app.shared.services.dynamo import ums_table # ← import = auto-detect with "crud"
dpl.configure(description="Auth Service")
# ums_table is auto-detected because it was imported
# app/features/tenant/routes.py
# Does NOT import ums_table → does NOT get permissions for it
dpl.configure(description="Tenant Service")
Permission overrides
Use dpl.configure(resources={...}) only when you need to restrict the default "crud" permission:
# app/features/auth/routes.py
from app.shared.services.kms_service import kms_key
dpl.configure(
resources={"kms_key": (kms_key, "decrypt")}, # restrict to decrypt only
)
How it works
| Resource location | Detection | Default permission | configure(resources=...) needed? |
|---|---|---|---|
Inside features/X/ (any file) |
Automatic via sys.modules scan |
crud |
Only to restrict permissions |
In shared/, imported in routes.py |
Automatic via namespace scan | crud |
Only to restrict permissions |
External (existing=True) |
Automatic (same rules) | crud |
Only to restrict permissions |
Deduplication
If the same resource appears in multiple features, the CloudFormation definition is emitted only once in the template, but each feature gets its own IAM policies and environment variables.
.env file and secrets
deployless can read a .env file to inject environment variables and manage secrets automatically.
Configuration in deployless.yaml
env_file: .env.production # Path to the .env file
# Optional — KMS key to encrypt secrets in SSM
secrets_kms: mi-app/secrets # Alias, key ID, or ARN
.env file format
# Normal variables — injected directly as env vars in all Lambdas
APP_ENV=production
LOG_FORMAT=json
# Secrets — the SECRET_ prefix indicates they are pushed to SSM Parameter Store
SECRET_DB_PASSWORD=mysecretpassword
SECRET_API_KEY=sk_live_xxxx
Behavior
| Type | Example | Destination | Value in Lambda |
|---|---|---|---|
| Normal | APP_ENV=production |
Direct env var | production |
| Secret | SECRET_DB_PASSWORD=xxx |
SSM Parameter Store | {{resolve:ssm:/mi-app/SECRET_DB_PASSWORD}} |
For SECRET_ variables:
- The name is kept in full with the prefix:
SECRET_DB_PASSWORD→/mi-app/SECRET_DB_PASSWORD - The value is stored as
Stringin SSM Parameter Store under the path/{app_name}/{VAR_NAME} - The Lambda receives a dynamic reference
{{resolve:ssm:...}}that CloudFormation resolves when creating/updating the stack - The env var in the Lambda also keeps the full name:
SECRET_DB_PASSWORD
Note:
String(notSecureString) is used because CloudFormation does not support{{resolve:ssm-secure:...}}in Lambda environment variables. The value is still protected by IAM — only roles withssm:GetParameterpermission can read it.
Validations
| Code | Rule |
|---|---|
| E27 | The specified env_file does not exist |
| E28 | SECRET_ variable with empty value |
| E29 | Invalid secrets_kms format (alias can only contain alphanumeric characters, -, _, /) |
Flask app initialization
deployless automatically uses your create_app() factory to initialize each Lambda — no extra configuration needed. CORS, error handlers, middleware, and any other setup you do inside create_app() is inherited automatically.
Zero-config: app factory auto-detection
If your project has a create_app() function in app/__init__.py, the generated bootstrap calls it directly:
# app/__init__.py — your existing code, unchanged
def create_app() -> Flask:
app = Flask(__name__)
CORS(app, origins=settings.CORS_ORIGINS)
register_error_handlers(app)
app.register_blueprint(todo_bp)
return app
deployless generates a bootstrap that calls create_app() transparently:
# .dist/TodoFunction/bootstrap.py — auto-generated
try:
from app import create_app as _deployless_factory
flask_app = _deployless_factory() # ← CORS, error handlers, etc. all applied
del _deployless_factory
except (ImportError, AttributeError) as _e:
# Fallback: manual bootstrap (see below)
...
For multi-feature projects, deployless also generates minimal stubs for the other features so create_app() can import all blueprints without errors — without including their actual code in the package:
.dist/TodoFunction/
app/
__init__.py ← real (with create_app)
features/
todo/ ← real code
auth/routes.py ← stub (empty Blueprint, ~5 lines)
user/routes.py ← stub (empty Blueprint, ~5 lines)
shared/
Fallback: manual bootstrap
If create_app() is not found or fails to import, deployless falls back to the manual bootstrap, registering blueprints directly. In this case, cors: and init_app: from deployless.yaml are used.
A warning is printed to stderr when the fallback is active:
[deployless] create_app() unavailable (...). Using manual bootstrap.
init_app (legacy / override)
init_app in deployless.yaml is the explicit alternative for projects that do not use an app/__init__.py factory. It is only applied in the fallback path, so it is a no-op when create_app() succeeds.
# deployless.yaml — only needed if you don't use create_app()
init_app:
- app.shared.errors.register_error_handlers
- app.shared.middleware.register_middleware
Each entry is a dotted module.function path. deployless imports and calls function(flask_app):
from app.shared.errors import register_error_handlers
register_error_handlers(flask_app)
Validation
| Code | Rule |
|---|---|
| E31 | Each init_app entry must be a string in module.function format (e.g. app.shared.errors.register_error_handlers) |
CLI commands
deployless init
Initializes a new deployless project with an interactive wizard.
deployless init # Creates only deployless.yaml
deployless init --app # Also creates app/, run.py, and .gitignore
Prompts for project name, stage, and runtime (with sensible defaults). The --app flag scaffolds the full project structure ready to start coding.
deployless build
Generates template.yaml and builds the .dist/ packages.
deployless build
# Options:
deployless build --stage prod # Overrides the stage
deployless build -o infra/template.yaml # Template output path
deployless build --dry-run # Validates without writing files
deployless build --verbose # Detailed output
deployless validate
Validates the project without generating any files. Equivalent to build --dry-run but with cleaner output.
deployless validate
deployless validate --stage prod
deployless validate --check-existing # Verifies that resources with existing=True exist in AWS
deployless validate --verbose
deployless check
Runs pre-flight checks before deploying: validates env vars, verifies the SAM CLI is installed, checks AWS credentials, and verifies that resources declared with existing=True actually exist in AWS.
deployless check
deployless check --stage prod
deployless check --verbose
deployless deploy
Chains deployless check + deployless build + sam build + sam deploy. Requires the AWS SAM CLI to be installed.
If samconfig.toml does not exist (first deployment), --guided is added automatically so SAM prompts for the initial configuration.
deployless deploy
deployless deploy --stage prod
deployless deploy --guided # Force wizard mode
deployless deploy --push-secrets # Push SECRET_ vars to SSM before deploying
deployless deploy --verbose # Detailed output
The --stage flag
The --stage flag overrides the stage value in deployless.yaml without editing the file. It affects:
- API Gateway
StageName— the URL prefix (/dev/,/prod/) APP_STAGEenv var — injected into all Lambda functionssamconfig.toml— the stack prefix for SAM
This is especially useful in CI/CD pipelines where a single deployless.yaml serves multiple environments:
# GitHub Actions — same deployless.yaml, different stage per branch
- run: deployless deploy --stage ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}
In local development you typically don't need it — stage: dev is the default.
deployless clean
Removes the generated files (.dist/ and template.yaml).
deployless clean
deployless clean -o infra/template.yaml # If you used a different output path
deployless info
Shows a summary of the detected project.
deployless info
Example output:
Project : mi-ums-api
Provider : aws
Stage : dev
Runtime : python3.13
Features (3):
- auth (app/features/auth/routes.py)
- tenant (app/features/tenant/routes.py)
- user (app/features/user/routes.py)
deployless secrets push
Pushes the SECRET_* variables from the .env file to AWS SSM Parameter Store.
deployless secrets push
deployless secrets push --stage prod
deployless secrets push --env-file .env.prod # Overrides the env_file path from deployless.yaml
deployless secrets push --verbose
Process:
- Reads the
.envfile (fromdeployless.yamlor--env-file) - Filters variables with the
SECRET_prefix - Creates/updates SSM parameters:
/{app_name}/{VAR_NAME}(typeString)
Note:
deployless builddoes not push secrets automatically. Usedeployless secrets pushexplicitly, or pass--push-secretstodeployless build/deployless deploy.
Example:
# .env.prod
SECRET_DB_PASSWORD=mysecretpassword
SECRET_API_KEY=sk_live_xxx
deployless secrets push --env-file .env.prod
# Creates in SSM:
# /mi-app/SECRET_DB_PASSWORD (String)
# /mi-app/SECRET_API_KEY (String)
deployless secrets sync
Push + removes orphaned parameters in SSM. Useful for keeping SSM in sync when secrets are removed from the .env.
deployless secrets sync
deployless secrets sync --stage prod
deployless secrets sync --env-file .env.prod
deployless secrets sync --yes # Auto-confirms deletion of orphans
deployless secrets sync --verbose
Behavior:
- Pushes all
SECRET_*variables (same assecrets push) - Lists existing parameters under
/{app_name}/in SSM - Detects parameters that are no longer in the
.env - Asks for confirmation before deleting them (unless
--yesis used)
Project structure
deployless expects the following directory structure (configurable in deployless.yaml):
mi-proyecto/
├── deployless.yaml # deployless configuration
├── requirements.txt # Global project dependencies
├── app/
│ ├── features/ # One folder per feature
│ │ ├── auth/
│ │ │ ├── routes.py # REQUIRED — Flask Blueprint + dpl.configure()
│ │ │ ├── use_cases/
│ │ │ ├── repositories/
│ │ │ └── schemas/
│ │ ├── user/
│ │ │ ├── routes.py
│ │ │ ├── requirements.txt # OPTIONAL — extra dependencies for this feature
│ │ │ └── ...
│ │ └── tenant/
│ │ └── routes.py
│ └── shared/ # Shared code — copied into ALL Lambdas
│ ├── decorators/
│ ├── errors/
│ └── config.py
└── .dist/ # Generated by deployless build (do not commit to git)
├── AuthFunction/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── features/
│ │ │ ├── __init__.py
│ │ │ └── auth/ # Only this feature's code
│ │ │ ├── routes.py
│ │ │ ├── use_cases/
│ │ │ └── ...
│ │ └── shared/ # Copy of app/shared/
│ ├── bootstrap.py # Auto-generated
│ ├── deployless.py # Runtime stub (no-ops)
│ └── requirements.txt # Global + feature + aws-wsgi requirements.txt
├── UserFunction/
└── TenantFunction/
Discovery rules
- deployless scans
app/features/looking for subdirectories with a valid entry point. - Directories starting with
_(e.g.__pycache__) are ignored. - They are processed in alphabetical order.
- The entry point must define at least one Flask Blueprint with at least one route.
Entry point resolution — deployless tries these conventions in order:
| Priority | Convention | Example |
|---|---|---|
| 1 | routes.py in the feature root |
features/auth/routes.py |
| 2 | routes/{feature}_routes.py (screaming architecture) |
features/auth/routes/auth_routes.py |
| 3 | Single .py file in routes/ |
features/auth/routes/handler.py |
This means you can organize your features in different ways without changing any configuration:
# Classic (default)
features/auth/routes.py
# Screaming architecture
features/auth/routes/auth_routes.py
# Custom name (only works if there's a single .py file in routes/)
features/auth/routes/endpoints.py
The generated bootstrap
For each Lambda a bootstrap.py is generated that:
- Creates a Flask app.
- Optionally injects CORS headers via
@after_request(ifapi.corsis configured andflask-corsis not in the dependencies). - Registers all Flask Blueprints found in
routes.py. - Calls any
init_apphooks declared indeployless.yaml. - Wraps the app with
aws_wsgi.response()to convert API Gateway events into WSGI requests.
# .dist/UserFunction/bootstrap.py — auto-generated, do not edit
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from flask import Flask
import app.features.user.routes as _routes_module
import inspect
flask_app = Flask(__name__)
# Injected when api.cors is configured and flask-cors is not in requirements
@flask_app.after_request
def _deployless_cors(response):
if 'Access-Control-Allow-Origin' not in response.headers:
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET,POST,PUT,DELETE,OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization'
return response
for _name, _obj in inspect.getmembers(_routes_module):
_klass = type(_obj)
if _klass.__name__ == "Blueprint" and "flask" in _klass.__module__:
flask_app.register_blueprint(_obj)
# Injected when init_app is configured in deployless.yaml
from app.shared.errors import register_error_handlers
register_error_handlers(flask_app)
try:
import awsgi
def handler(event, context):
return awsgi.response(flask_app, event, context, base64_content_types={"image/png", "image/jpeg"})
except ImportError:
raise ImportError("aws-wsgi is required.")
CORS auto-injection: if api.cors is configured, deployless always injects the @after_request hook. The hook has a guard — it only sets the headers if Access-Control-Allow-Origin is not already present. This means it coexists safely with flask-cors: if the user registers CORS(flask_app, ...) via init_app, flask-cors runs first (LIFO hook order) and the deployless hook becomes a no-op.
Important:
flask-corsinrequirements.txtorpyproject.tomlis not enough. The bootstrap creates its own Flask app and never callscreate_app(), so any CORS setup in the app factory is not applied. To useflask-corsin Lambda, register it explicitly viainit_app.
Each Lambda also includes a deployless.py with no-op implementations of all deployless functions (configure, KMS, DynamoDB, etc.), so that import deployless as dpl statements in routes.py do not fail at runtime without needing to install the full package.
Full example
This example uses the real app in this repository (app/features/auth, user, tenant).
1. deployless.yaml
name: ums-api
provider: aws
stage: dev
paths:
features: app/features
shared: app/shared
globals:
runtime: python3.13
memory: 256
timeout: 30
log_retention: 14
api:
endpoint_type: REGIONAL
cors:
allow_origin: "*"
allow_methods: [GET, POST, PUT, DELETE, OPTIONS]
allow_headers: [Content-Type, Authorization, X-Api-Key]
env:
LOG_LEVEL: INFO
2. app/features/user/routes.py
from flask import Blueprint, request, g, jsonify
import deployless as dpl
from app.features.user.schemas import CreateUserRequest, UpdateUserRequest
from app.features.user.use_cases import create_user, list_users, get_user, update_user, delete_user
from app.shared.decorators import require_auth, require_scopes
# ---- Lambda configuration for the "user" feature ----
dpl.configure(
memory=512,
timeout=30,
description="User Management Service",
resources={
"users": dpl.DynamoDB(
"ums-users",
pk="tenant_id",
pk_type="S",
sk="user_id",
sk_type="S",
gsi=[
{
"name": "EmailIndex",
"pk": "email",
"pk_type": "S",
}
],
ttl_attribute="expires_at",
deletion_policy="Retain",
),
"sessions": dpl.DynamoDB(
"ums-sessions",
pk="session_id",
ttl_attribute="expires_at",
),
},
env={
"TOKEN_EXPIRY": "3600",
},
)
# ---- Cron: daily cleanup of expired sessions ----
@dpl.cron(
schedule="rate(24 hours)",
memory=128,
timeout=60,
description="Limpieza de sesiones expiradas",
)
def cleanup_sessions(event, context):
# Cleanup logic
return {"status": "ok"}
# ---- Flask Blueprint ----
user_bp = Blueprint("user_bp", __name__, url_prefix="/users")
@user_bp.route("", methods=["POST"])
@require_auth
@require_scopes(["ums:users:create"])
def create_user_route():
data = request.get_json()
req = CreateUserRequest(
email=data.get("email"),
password=data.get("password"),
scopes=data.get("scopes", []),
)
response = create_user(req, g.user["tenant_id"])
return jsonify(response.to_dict()), 201
@user_bp.route("", methods=["GET"])
@require_auth
@require_scopes(["ums:users:read"])
def list_users_route():
response = list_users(g.user["tenant_id"])
return jsonify(response.to_dict()), 200
@user_bp.route("/<user_id>", methods=["GET"])
@require_auth
@require_scopes(["ums:users:read"])
def get_user_route(user_id):
response = get_user(g.user["tenant_id"], user_id)
return jsonify(response.to_dict()), 200
@user_bp.route("/<user_id>", methods=["PUT"])
@require_auth
@require_scopes(["ums:users:update"])
def update_user_route(user_id):
data = request.get_json()
req = UpdateUserRequest(
email=data.get("email"),
password=data.get("password"),
scopes=data.get("scopes"),
)
response = update_user(g.user["tenant_id"], user_id, req)
return jsonify(response.to_dict()), 200
@user_bp.route("/<user_id>", methods=["DELETE"])
@require_auth
@require_scopes(["ums:users:delete"])
def delete_user_route(user_id):
delete_user(g.user["tenant_id"], user_id)
return "", 204
# ---- Split Lambda: heavy export ----
@dpl.route(memory=1024, timeout=120, description="Exportación masiva de usuarios")
@user_bp.route("/export", methods=["POST"])
@require_auth
@require_scopes(["ums:users:export"])
def export_users_route():
# This endpoint will have its own Lambda
...
return jsonify({"url": "https://..."}), 200
3. Run the build
deployless build --verbose
Expected output:
[deployless] Project: ums-api | Stage: dev | Provider: aws
[deployless] Features found: ['auth', 'tenant', 'user']
[deployless] auth: 3 routes, 0 split
[deployless] tenant: 2 routes, 0 split
[deployless] user: 5 routes, 1 split
[deployless] Crons: ['cleanup_sessions']
[deployless] Validation passed.
[deployless] Built: .dist/AuthFunction
[deployless] Built: .dist/TenantFunction
[deployless] Built: .dist/UserFunction
[deployless] Built split route: .dist/ExportUsersRouteFunction
[deployless] Built cron: .dist/CleanupSessionsFunction
[deployless] Template generated: /path/to/project/template.yaml
4. Deploy
# First time (SAM interactive wizard)
deployless deploy --guided --stage prod
# Subsequent deployments
deployless deploy --stage prod
5. Generated template.yaml (summary)
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ums-api — Generated by deployless
Globals:
Function:
Runtime: python3.13
MemorySize: 256
Timeout: 30
Environment:
Variables:
LOG_LEVEL: INFO
APP_STAGE: dev
Resources:
Api:
Type: AWS::Serverless::Api
Properties:
StageName: dev
EndpointConfiguration: REGIONAL
Cors:
AllowOrigin: "'*'"
AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization,X-Api-Key'"
UserFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: .dist/UserFunction/
Handler: bootstrap.handler
MemorySize: 512
Timeout: 30
Description: User Management Service
Environment:
Variables:
UMS_USERS_TABLE:
Ref: UmsUsersTable
UMS_SESSIONS_TABLE:
Ref: UmsSessionsTable
TOKEN_EXPIRY: '3600'
Events:
UserPostGet:
Type: Api
Properties:
RestApiId:
Ref: Api
Path: /users
Method: get
# ... more events
UmsUsersTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: Retain
Properties:
TableName: ums-users
BillingMode: PAY_PER_REQUEST
# ... attributes, GSI, TTL
CleanupSessionsFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: .dist/CleanupSessionsFunction/
Handler: bootstrap.handler
MemorySize: 128
Timeout: 60
Events:
Schedule:
Type: Schedule
Properties:
Schedule: rate(24 hours)
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value:
Fn::Sub: https://${Api}.execute-api.${AWS::Region}.amazonaws.com/dev
UserFunctionArn:
Value:
Fn::GetAtt: [UserFunction, Arn]
# ...
Known notes and limitations
- Only Flask is supported for now. FastAPI support is planned.
- The full feature directory tree is copied into each Lambda (including subdirectories like
use_cases/,repositories/, etc.). app/shared/is copied in full into each Lambda underapp/shared/. Imports likefrom app.shared.x import ywork without changes in Lambda.- Dependencies are not installed during
deployless build.sam build(run bydeployless deploy) is what installs therequirements.txtof each package. - SQS and KMS resources return multiple CloudFormation entries (queue + DLQ, key + alias). deployless inserts them all correctly into the template.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file deployless-0.1.5.tar.gz.
File metadata
- Download URL: deployless-0.1.5.tar.gz
- Upload date:
- Size: 157.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d655a3ce489bb899ebec2e4e4b557cda3edbdb97305d51de569f296ff665d29d
|
|
| MD5 |
cbeec07903cccddb479cbea84081cf8c
|
|
| BLAKE2b-256 |
1ce16ed7c0632db5a7516cca714d08d543e95466b3d04235e54a39baa345efa6
|
File details
Details for the file deployless-0.1.5-py3-none-any.whl.
File metadata
- Download URL: deployless-0.1.5-py3-none-any.whl
- Upload date:
- Size: 92.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8212e513cf16ea4119e37e1e0e2051805b9312cf1beb2dd89fd5b412e5198d0d
|
|
| MD5 |
13b6065718f3db0dfadf4431e7b4fe08
|
|
| BLAKE2b-256 |
38c070a679a74d5e967fd279790c1da6939768c55dd8b49b9a5b592ab9ad2cc2
|