Deploying SaaS Inside a Customer's AWS VPC: Architecture and Compliance
Once you decide to deploy SaaS in a customer's AWS VPC, the problem stops being "how do I ship an app" and becomes "how do I operate an app I can only partially see, in an account I don't own, without holding standing credentials to it." This is the architecture and the compliance posture that make that workable — written for the technical decision-maker who has to defend it in a security review.
The access model: cross-account roles, never long-lived keys
The foundational rule: you never store the customer's credentials. Instead, the customer creates an IAM role in their account that trusts your account to assume it. Your automation calls sts:AssumeRole, receives short-lived credentials (an hour by default), and does its work. Nothing durable lives on your side.
The trust policy on the customer's role, hardened with an ExternalId to prevent the confused-deputy problem:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<YOUR_VENDOR_ACCOUNT_ID>:role/deployer" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "<PER_CUSTOMER_RANDOM_ID>" }
}
}]
}The ExternalId is unique per customer and known only to the two parties — so even another of your customers can't trick your deployer into acting on their tenant. This single pattern is what lets a security team approve the integration: revocation is one role deletion away, and there is no key of yours sitting in their account to leak.
Least privilege, scoped to exactly what the deploy needs
The permissions policy attached to that role should grant only the services your stack touches — not *. If you deploy on ECS Fargate behind an ALB with an RDS database, the role needs ECS, ELB, EC2 networking, RDS, IAM (to create task roles), CloudWatch, and the specific S3/KMS resources you manage. A reviewer will read this policy line by line; every wildcard is a question you will have to answer.
The test for least privilege: if a permission were removed, would a deploy fail? If you can't say yes, it doesn't belong in the policy.
Infrastructure as code: a Terraform-provisioned VPC
Everything you create in the customer's account should be defined in code, both so it is reproducible and so the customer can audit exactly what will exist before you run it. A minimal, isolated network — private subnets for the app and database, public only for the load balancer:
module "network" {
source = "terraform-aws-modules/vpc/aws"
name = "${var.customer}-app"
cidr = "10.42.0.0/16"
azs = ["${var.region}a", "${var.region}b"]
private_subnets = ["10.42.1.0/24", "10.42.2.0/24"]
public_subnets = ["10.42.101.0/24", "10.42.102.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # one per AZ for real availability
tags = {
Vendor = "deployforme"
Customer = var.customer
ManagedBy = "terraform"
}
}Run this with the assumed-role credentials and a remote state backend that lives in the customer's account (an S3 bucket + DynamoDB lock table), so the source of truth for their infrastructure never leaves their environment either.
The compute pattern: ECS/Fargate for a clean blast radius
For BYOC, Fargate is usually the right default: no EC2 hosts to patch, tasks run in private subnets, and each service is a small, well-scoped unit. The app task pulls its image from a registry the customer can mirror, reads secrets from AWS Secrets Manager or SSM Parameter Store (never baked into the image), and talks to RDS over a security group that allows only the app's tasks:
resource "aws_security_group_rule" "app_to_db" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aws_security_group.db.id
source_security_group_id = aws_security_group.app.id
}The database is never internet-reachable. The load balancer terminates TLS and is the only public surface. If the app is compromised, the blast radius is one task role and one security group — not the whole account.
Audit logging the customer already trusts
A major reason enterprises prefer BYOC is that everything you do lands in their existing audit tooling. Because you operate via an assumed role, every API call your automation makes is recorded in the customer's CloudTrail with your role's identity and the ExternalId. Turn that into a feature:
- CloudTrail captures the control-plane actions — who assumed the role and what they changed.
- VPC Flow Logs capture network activity for the app subnets.
- Application logs ship to the customer's CloudWatch, so their SOC can alert on them with the same rules they use for everything else.
The customer can answer "what did this vendor do in our account last Tuesday?" from tools they already own. That is a very different conversation than "trust our internal logs," and it is why this model clears reviews that a shared SaaS can't — the same reason compliance teams reject multi-tenant PaaS.
Operating it: updates without standing access
The last piece is day-two operations. Because you don't hold permanent credentials, your CI/CD pipeline assumes the customer role at deploy time, applies the Terraform plan and rolls the ECS service, then drops the credentials. Customers can gate this further by requiring the role only be assumable from your CI's IP ranges, or by approving each plan. It is more moving parts than pushing to a PaaS — which is the honest tradeoff of BYOC — but every part exists to answer a specific question a security reviewer will ask.
If your team hasn't built cross-account deployment automation before, the first customer VPC is where a lot of subtle time goes — trust policies, state isolation, per-tenant pipelines, and the audit story that ties it together. That end-to-end setup is exactly the kind of work we take off engineering teams so the deal can close on the customer's timeline instead of your infrastructure backlog.
Rather have someone handle this end-to-end?
If you'd rather not become an infrastructure engineer to ship your project, we take a GitHub repo and handle the whole deployment — managed for you, or inside your own AWS, GCP, or Azure. No developer needed on your side.
Get your project deployed →