If you combine the Serverless Framework and GitHub Actions, permissions quickly become an uncomfortable topic. OIDC is set up, no more access keys in the secrets, all good. And then the deploy role still carries AdministratorAccess “because the framework needs so much”. I keep running into this pattern in examples, and I get it: the official docs don’t offer a ready-made least-privilege policy.
In my article on GitHub Actions OIDC I built the trust policy with AWS CDK and only hinted at where the Serverless Framework works differently. Today I’m filling that gap: how to cut the deploy role for the Serverless Framework so that a hijacked workflow can’t rebuild half of your account. At the end you’ll have a bootstrap template, a matching serverless.yml and a workflow you can adopt right away.
In short
With the Serverless Framework, the deploy role from GitHub Actions should only drive CloudFormation, upload artifacts and pass exactly one service role. CloudFormation creates the actual resources with that service role, and every function role gets a permissions boundary. The biggest pitfall: once a stack has a service role, you can’t remove it again, so plan it properly from the start.
What you will learn in this article
- Why OIDC alone doesn’t mean least privilege with the Serverless Framework
- How deploy role, CloudFormation service role and permissions boundary work together
- A working bootstrap template,
serverless.ymland GitHub workflow - Which error messages are typical and how to fix them precisely
Why Serverless Framework and GitHub Actions need their own permission model
With AWS CDK things are fairly relaxed. The CLI assumes the roles from the bootstrap stack, and your pipeline essentially only needs sts:AssumeRole on those roles. The Serverless Framework works differently. It calls CloudFormation and S3 directly with whatever credentials are currently active. In GitHub Actions, those are the credentials of your OIDC role.
Without further precautions, this is what happens: CloudFormation creates every resource on behalf of that role. If the stack is supposed to create a Lambda function, an IAM role and an HTTP API, the deploy role itself must be allowed to do all of that. For the function’s IAM role this means iam:CreateRole and iam:PutRolePolicy. Anyone holding those two permissions without restrictions can build themselves a role with arbitrary permissions. That’s why many teams end up reaching for the admin policy after all, and the security gain from OIDC shrinks to “at least there’s no key in the secret”.
That’s not nothing. Still, the second step pays off, because a workflow is code, and code can be changed. A compromised dependency in the build or a carelessly merged pull request is enough for an attacker to work with exactly these permissions.
The target picture: three roles with clear boundaries
The solution spreads the permissions across three levels. Each level may only do what it really needs, and each one limits the next.
The deploy role only drives CloudFormation, the service role creates resources, and the boundary caps every function role.
The deploy role is assumed by the workflow via OIDC. It may create and update your service’s stack, write artifacts to the deployment bucket and pass exactly one role to CloudFormation. It may not create Lambda functions or IAM roles itself.
The CloudFormation service role is the role CloudFormation uses to actually create the resources. It may manage Lambda functions, HTTP APIs and log groups, but only with your service’s name prefix. It may only create IAM roles if a specific permissions boundary is attached.
The function role is generated by the Serverless Framework from your serverless.yml as usual. It gets the boundary, and the boundary defines the maximum this role can ever be allowed to do. Even if someone adds generous statements to serverless.yml, only the intersection takes effect.
Why a CloudFormation service role?
A service role (in serverless.yml it’s called deploymentRole) separates two questions: who may trigger a deploy, and what may be created along the way? Without a service role, both answers are the same policy. With a service role, the deploy role answers only the first question and the service role the second.
The key is the cloudformation:RoleARN condition in the deploy role. With it, the pipeline may only create or change stacks if it passes exactly this service role. A deploy without the service role, which would then run with the pipeline’s own permissions, fails immediately.
What the permissions boundary prevents
A permissions boundary is a managed IAM policy attached to a role as an upper limit. It grants no permissions by itself. Only what both the boundary and the role’s policies allow is effective. The service role may only create new roles with exactly this boundary and may not remove it afterwards. As a result, “may create IAM roles” turns into a manageable “may create roles that can at most write logs, access their own tables and send traces”.
🎬 Serverless Framework GitHub Actions: how a deploy flows – this animated diagram only runs on my site: watch it on sascha-ms.de
Set up Serverless Framework with GitHub Actions step by step
The example is a small API for a bike rental: a Lambda function behind an HTTP API that returns free bikes per station. It’s deliberately small so that the permissions stay in focus. For larger services you extend the service role, while the deploy role stays the same.
Prerequisites
- An AWS account and credentials with admin permissions for the one-time bootstrap (locally or via IAM Identity Center)
- AWS CLI v2 and the GitHub CLI
gh - Node.js 24 and a repository on GitHub, here
example-org/bike-rental-api - An account for Serverless Framework v4 and an access key from the dashboard (Settings → Access Keys)
A word on licensing: since version 4, the CLI requires you to sign in. According to the pricing page, it stays free for organizations that earned at most 2 million US dollars in their last fiscal year. Above that, you need a subscription. How to get to v4 in the first place is covered in my article on migrating to Serverless Framework v4.
Step 1: Create the bootstrap template
You deploy the template once per account and service with admin permissions. It contains the OIDC provider (optional, in case it already exists), a dedicated deployment bucket, the boundary, the service role and the deploy role. I deliberately use plain CloudFormation here, so it works regardless of the tool you build the service itself with.
# bootstrap/deploy-access.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Deploy access for GitHub Actions and the Serverless Framework (once per account and service)
Parameters:
ServiceName:
Type: String
Default: bike-rental-api
AllowedPattern: '^[a-z0-9-]{3,30}$'
GitHubSubject:
Type: String
Description: Exact sub claim, e.g. repo:example-org/bike-rental-api:environment:prod
CreateOidcProvider:
Type: String
AllowedValues: ['true', 'false']
Default: 'true'
Conditions:
CreateProvider: !Equals [!Ref CreateOidcProvider, 'true']
Resources:
GitHubOidcProvider:
Type: AWS::IAM::OIDCProvider
Condition: CreateProvider
Properties:
Url: https://token.actions.githubusercontent.com
ClientIdList:
- sts.amazonaws.com
# Own deployment bucket instead of the auto-created one
DeploymentBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub '${ServiceName}-deploy-${AWS::AccountId}-${AWS::Region}'
VersioningConfiguration:
Status: Enabled
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
LifecycleConfiguration:
Rules:
- Id: expire-old-versions
Status: Enabled
NoncurrentVersionExpiration:
NoncurrentDays: 30
DeploymentBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref DeploymentBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: TlsOnly
Effect: Deny
Principal: '*'
Action: 's3:*'
Resource:
- !GetAtt DeploymentBucket.Arn
- !Sub '${DeploymentBucket.Arn}/*'
Condition:
Bool:
aws:SecureTransport: 'false'
# Upper limit for all function roles the service creates
FunctionBoundary:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: !Sub 'deploy-${ServiceName}-function-boundary'
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: Logs
Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
- logs:TagResource
Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${ServiceName}-*'
- Sid: Tables
Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Query
Resource: !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ServiceName}-*'
- Sid: Tracing
Effect: Allow
Action:
- xray:PutTraceSegments
- xray:PutTelemetryRecords
Resource: '*'
# CloudFormation uses this role to create the resources
CfnExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub 'deploy-${ServiceName}-cfn-exec'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: cloudformation.amazonaws.com
Action: sts:AssumeRole
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
Policies:
- PolicyName: service-resources
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: Functions
Effect: Allow
Action: 'lambda:*'
Resource:
- !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${ServiceName}-*'
- Sid: HttpApi
Effect: Allow
Action:
- apigateway:GET
- apigateway:POST
- apigateway:PUT
- apigateway:PATCH
- apigateway:DELETE
Resource:
- !Sub 'arn:aws:apigateway:${AWS::Region}::/apis'
- !Sub 'arn:aws:apigateway:${AWS::Region}::/apis/*'
- !Sub 'arn:aws:apigateway:${AWS::Region}::/tags/*'
- Sid: LogGroups
Effect: Allow
Action:
- logs:CreateLogGroup
- logs:DeleteLogGroup
- logs:PutRetentionPolicy
- logs:DeleteRetentionPolicy
- logs:TagResource
- logs:UntagResource
- logs:ListTagsForResource
Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${ServiceName}-*'
- Sid: DescribeLogGroups
Effect: Allow
Action: logs:DescribeLogGroups
Resource: '*'
- Sid: RolesOnlyWithBoundary
Effect: Allow
Action:
- iam:CreateRole
- iam:PutRolePolicy
- iam:DeleteRolePolicy
- iam:AttachRolePolicy
- iam:DetachRolePolicy
- iam:PutRolePermissionsBoundary
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ServiceName}-*'
Condition:
StringEquals:
iam:PermissionsBoundary: !Ref FunctionBoundary
- Sid: ManageRoles
Effect: Allow
Action:
- iam:GetRole
- iam:GetRolePolicy
- iam:DeleteRole
- iam:TagRole
- iam:UntagRole
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ServiceName}-*'
- Sid: PassRolesToLambda
Effect: Allow
Action: iam:PassRole
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ServiceName}-*'
Condition:
StringEquals:
iam:PassedToService: lambda.amazonaws.com
- Sid: KeepBoundary
Effect: Deny
Action: iam:DeleteRolePermissionsBoundary
Resource: '*'
- Sid: ReadCode
Effect: Allow
Action: s3:GetObject
Resource: !Sub '${DeploymentBucket.Arn}/*'
# The GitHub workflow assumes this role via OIDC
GitHubDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub 'deploy-${ServiceName}-github'
MaxSessionDuration: 3600
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Federated: !If
- CreateProvider
- !Ref GitHubOidcProvider
- !Sub 'arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com'
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
token.actions.githubusercontent.com:sub: !Ref GitHubSubject
Policies:
- PolicyName: serverless-deploy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: StackOnlyWithServiceRole
Effect: Allow
Action:
- cloudformation:CreateStack
- cloudformation:UpdateStack
- cloudformation:CreateChangeSet
Resource: !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${ServiceName}-*/*'
Condition:
StringEquals:
cloudformation:RoleARN: !GetAtt CfnExecutionRole.Arn
- Sid: ReadStack
Effect: Allow
Action:
- cloudformation:DescribeStacks
- cloudformation:DescribeStackEvents
- cloudformation:DescribeStackResource
- cloudformation:DescribeStackResources
- cloudformation:ListStackResources
- cloudformation:GetTemplate
- cloudformation:DescribeChangeSet
- cloudformation:ExecuteChangeSet
- cloudformation:DeleteChangeSet
Resource: !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${ServiceName}-*/*'
- Sid: ValidateTemplate
Effect: Allow
Action: cloudformation:ValidateTemplate
Resource: '*'
- Sid: ReadBucket
Effect: Allow
Action:
- s3:ListBucket
- s3:GetBucketLocation
Resource: !GetAtt DeploymentBucket.Arn
- Sid: WriteArtifacts
Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
- s3:DeleteObject
Resource: !Sub '${DeploymentBucket.Arn}/serverless/${ServiceName}/*'
- Sid: PassOnlyTheServiceRole
Effect: Allow
Action: iam:PassRole
Resource: !GetAtt CfnExecutionRole.Arn
Condition:
StringEquals:
iam:PassedToService: cloudformation.amazonaws.com
- Sid: ReadFrameworkParameters
Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/serverless-framework/*'
Outputs:
DeploymentBucketName:
Value: !Ref DeploymentBucket
CfnExecutionRoleArn:
Value: !GetAtt CfnExecutionRole.Arn
FunctionBoundaryArn:
Value: !Ref FunctionBoundary
GitHubDeployRoleArn:
Value: !GetAtt GitHubDeployRole.Arn
A few details matter to me. The template’s roles start with deploy- and therefore don’t fall under the bike-rental-api-* prefix the service role may manage. Otherwise the service role could delete or rebuild the deploy role. The dedicated bucket replaces the shared bucket the framework would otherwise create automatically. That way, the deploy role needs neither s3:CreateBucket nor write access to bucket policies. I checked the template with cfn-lint. Incidentally, that revealed that API Gateway doesn’t know apigateway:TagResource; tags there go through the regular HTTP verbs.
Step 2: Deploy the template once
For the GitHubSubject parameter, enter exactly the sub claim your repository sends. For repositories created after July 15, 2026, it also contains numeric IDs. How to check the format is explained in the OIDC article. If the OIDC provider already exists in the account, set CreateOidcProvider=false.
aws cloudformation deploy \
--stack-name deploy-bike-rental-api \
--template-file bootstrap/deploy-access.yml \
--capabilities CAPABILITY_NAMED_IAM \
--region eu-central-1 \
--parameter-overrides \
GitHubSubject=repo:example-org/bike-rental-api:environment:prod \
CreateOidcProvider=true
aws cloudformation describe-stacks \
--stack-name deploy-bike-rental-api \
--region eu-central-1 \
--query "Stacks[0].Outputs" --output table
The output shows four values: bucket name, service role ARN, boundary ARN and deploy role ARN. You’ll need them in GitHub in a moment.
Step 3: Adjust serverless.yml
In serverless.yml you reference the dedicated bucket, the service role and the boundary. I read the values from environment variables. If one of them is missing, the framework stops with a clear message instead of silently working with a different bucket.
# serverless.yml
service: bike-rental-api
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs24.x
architecture: arm64
region: eu-central-1
stage: ${opt:stage, 'dev'}
memorySize: 256
logRetentionInDays: 14
deploymentBucket:
name: ${env:DEPLOYMENT_BUCKET}
iam:
# CloudFormation creates the resources with this role
deploymentRole: ${env:CFN_EXECUTION_ROLE_ARN}
role:
# Every function role gets the upper limit from the bootstrap
permissionsBoundary: ${env:FUNCTION_BOUNDARY_ARN}
functions:
stations:
handler: src/stations.handler
events:
- httpApi:
path: /stations
method: get
With permissionsBoundary under provider.iam.role, the framework attaches the boundary to the generated function role. Without this line, IAM rejects the role creation, because the service role may only create roles with a boundary. That’s intentional: the safeguard doesn’t depend on someone remembering the line.
Step 4: The function
The function itself is unspectacular. Serverless Framework v4 builds TypeScript handlers with esbuild out of the box, so you don’t need an extra plugin.
// src/stations.ts
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
interface Station {
id: string;
name: string;
freeBikes: number;
}
const stations: Station[] = [
{ id: 'central', name: 'Central Station', freeBikes: 7 },
{ id: 'harbour', name: 'Harbour', freeBikes: 2 },
{ id: 'uni', name: 'University', freeBikes: 0 },
];
export const handler: APIGatewayProxyHandlerV2 = async () => {
const available = stations.filter((s) => s.freeBikes > 0);
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ stations: available }),
};
};
Step 5: Store variables and the secret in GitHub
Create an environment prod in GitHub and store the four values there as variables. The ARNs aren’t secrets, so variables are enough. Only the access key for the Serverless Framework belongs in a secret. Use a dedicated key with a descriptive name such as github-ci, not your personal one. Your personal key stops working as soon as you leave the organization.
gh api -X PUT repos/example-org/bike-rental-api/environments/prod
gh variable set AWS_DEPLOY_ROLE_ARN --env prod --body "arn:aws:iam::111122223333:role/deploy-bike-rental-api-github"
gh variable set DEPLOYMENT_BUCKET --env prod --body "bike-rental-api-deploy-111122223333-eu-central-1"
gh variable set CFN_EXECUTION_ROLE_ARN --env prod --body "arn:aws:iam::111122223333:role/deploy-bike-rental-api-cfn-exec"
gh variable set FUNCTION_BOUNDARY_ARN --env prod --body "arn:aws:iam::111122223333:policy/deploy-bike-rental-api-function-boundary"
gh secret set SERVERLESS_ACCESS_KEY --env prod
Protection rules for the environment (for example approval by a second person, or only the main branch) give you a second safeguard. The role’s trust policy accepts tokens from exactly this environment anyway.
Step 6: The workflow
The workflow exchanges the OIDC token for credentials of the deploy role and starts the deploy. The workflow’s own permissions are limited to id-token: write and contents: read.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: prod
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx tsc --noEmit
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: gh-deploy-${{ github.run_id }}
aws-region: eu-central-1
- run: npx serverless deploy --stage prod
env:
SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }}
DEPLOYMENT_BUCKET: ${{ vars.DEPLOYMENT_BUCKET }}
CFN_EXECUTION_ROLE_ARN: ${{ vars.CFN_EXECUTION_ROLE_ARN }}
FUNCTION_BOUNDARY_ARN: ${{ vars.FUNCTION_BOUNDARY_ARN }}
Step 7: Check the result
After the first run, the framework reports, roughly speaking, that the service was deployed to the stack bike-rental-api-prod and shows the HTTP API endpoint. Two checks are worth it:
aws cloudformation describe-stacks \
--stack-name bike-rental-api-prod \
--region eu-central-1 \
--query "Stacks[0].RoleARN" --output text
curl -s https://abc123xyz.execute-api.eu-central-1.amazonaws.com/stations
The first command must print the ARN of deploy-bike-rental-api-cfn-exec. If it says None, the stack runs without a service role. The second returns the stations with free bikes as JSON, so Central Station and Harbour. Also take a look at the function role in the IAM console: under “Permissions boundary” you should see the boundary from the bootstrap.
What the deploy role may do and what it may not
The following overview helps me whenever someone asks why a particular action is in the policy. It also shows what is deliberately missing.
| Permission | Purpose | Scope |
|---|---|---|
cloudformation:CreateStack, UpdateStack, CreateChangeSet
|
Create and update the stack | only prefixed stacks and only with the service role |
cloudformation:Describe*, GetTemplate
|
Follow progress, read outputs | only prefixed stacks |
s3:PutObject, GetObject, DeleteObject
|
Upload packages, compare hashes, clean up old artifacts | only the path serverless/bike-rental-api/
|
iam:PassRole |
Pass the service role to CloudFormation | one role, only to CloudFormation |
ssm:GetParameter |
Read framework parameters, e.g. a license key | only /serverless-framework/*
|
| not included |
lambda:*, iam:CreateRole, cloudformation:DeleteStack
|
handled by the service role or a human |
DeleteStack is missing on purpose. No workflow should be able to run a serverless remove against production on the side. For short-lived environments, say per pull request, you’re better off creating a separate role with its own stack prefix.
To be clear, this policy is a good starting point, not a guarantee for every configuration. Depending on the framework version, plugins and variables such as ${cf:...}, additional read calls may come up. The approach is always the same: run the deploy, read the action from the error message or from CloudTrail, and add it specifically, scoped to the matching resource.
Common errors and how to fix them
AccessDenied on cloudformation:CreateStack
Usually deploymentRole is missing from serverless.yml or the environment variable is empty. Then the framework calls CreateStack without a service role, and the cloudformation:RoleARN condition kicks in. Check the workflow log to see whether all four variables are set.
Not authorized to perform iam:PassRole
If the message names the role deploy-bike-rental-api-cfn-exec, the ARN in the variable doesn’t match the role from the bootstrap. If instead a role with the prefix bike-rental-api- shows up, the error comes from CloudFormation: the service role then lacks the permission to pass the function role to Lambda, for instance because the service name doesn’t match the prefix.
iam:CreateRole is denied
This happens when permissionsBoundary is missing or points to a different policy. The stack then rolls back, and the stack events show the denied action. Compare the ARN with the FunctionBoundaryArn output.
New resource types in the stack
If you add a DynamoDB table or an SQS queue under resources, the service role needs matching permissions, such as dynamodb:CreateTable on table/bike-rental-api-*. The deploy role stays unchanged. That’s the real benefit of this setup: changes to the service affect only one place, and it doesn’t live in the service’s repository.
Existing stack without a service role
An existing stack can be updated with a service role on its next update. The reverse doesn’t work: according to the AWS documentation, you can’t remove a service role from a stack once it has been set. In addition, anyone with permissions on the stack can use this role, even without their own iam:PassRole. So keep the service role really narrow.
Costs and security
IAM roles, boundaries and OIDC cost nothing. Depending on package size, the deployment bucket costs a few cents per month. The lifecycle rule deletes old object versions after 30 days, and the framework itself keeps the last five deployments by default. With the Serverless Framework, the access key is the real weak spot, because it’s tied to a user. Rotate it regularly and limit the secret to the prod environment so that workflows from other branches can’t even get to it.
My take
I like the Serverless Framework for its concise serverless.yml, and I don’t want to make it look worse than it is. When it comes to permissions, though, you can tell it comes from a time when “admin in the dev account” was normal. CDK has the better foundation with its bootstrap concept. With a service role and a boundary, however, the Serverless Framework catches up almost completely, and the effort is a one-off.
Where I disagree: to me, “least privilege” doesn’t mean filing every single action down to the minimum until every new plugin version breaks the deploy. What matters more to me is that the dangerous permissions (creating roles, passing roles, deleting stacks) are cleanly limited. I sleep well with lambda:* on functions with a fixed prefix. With iam:* on *, I don’t.
FAQ
Do I need a license key for Serverless Framework v4 in GitHub Actions?
No, an access key from the dashboard is enough; you store it as SERVERLESS_ACCESS_KEY. A license key is an alternative that isn’t tied to a user, but it requires an active subscription.
Can I keep using the automatically created deployment bucket?
Yes, but then the deploy role also needs permissions to create the bucket and for the SSM parameter where the framework stores the bucket name. A dedicated bucket from the bootstrap keeps the deploy role leaner.
Does this work with multiple stages?
Yes. The cleanest option is one account per stage with its own bootstrap. Within the same account, create a separate deploy role per stage with its own GitHub environment in the sub claim.
Do I have to specify the service role on every deploy?
CloudFormation remembers the role on the stack. The framework passes it on every deploy anyway, and because of the condition in the deploy role, it has to.
Your next step
Take a look at the deploy role of your most important Serverless Framework service. If it carries AdministratorAccess or iam:*, grab the bootstrap template, adapt the service role to your resources and roll it out in a test account first. Which action did you have to add first? Feel free to share it in the comments, I’m collecting the cases.
Sources
- Serverless Framework: Running in your own CI/CD
- Serverless Framework: Deployment Bucket
- Serverless Framework: serverless.yml Reference
- Serverless Framework: IAM Permissions
- Serverless Framework: License Keys
- Serverless Inc.: Pricing and FAQ
- AWS CloudFormation: Service role
- AWS CloudFormation: Controlling access with IAM
- AWS IAM: Permissions boundaries
- aws-actions/configure-aws-credentials: Releases
This post was created with AI assistance.
👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.
📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)