Building a Zero-Trust Internal Developer Platform on AWS
Internal developer platforms (IDPs) are having a moment. Every platform engineering team is talking about them, vendors are selling expensive SaaS solutions, and Backstage plugins are multiplying faster than anyone can track. But if you're a small-to-medium engineering team, you probably don't need Backstage. You need something your developers will actually use, that your security team won't object to, and that doesn't cost $2,000 per month in tooling fees.
This post walks through the architecture of our internal developer platform โ a Node.js self-service portal built on AWS, secured with Cognito, and backed by DynamoDB. The key design principle throughout: zero trust. No implicit access, no shared credentials, no "just this once" workarounds.
What We Were Trying to Solve
Before the IDP existed, production deployments required a developer to either:
- Have direct AWS console access (bad โ wide blast radius, no audit trail)
- Push a Git commit and wait for an engineer with pipeline access to manually trigger a deploy (slow, creates bottlenecks)
- Ping someone on Slack to "just run the deploy" (the actual thing that was happening)
The problem wasn't just toil โ it was the lack of a structured approval workflow. Prod deployments were either blocked or wild, with no middle ground. We needed a system where developers could self-serve dev and test deployments, but production required explicit approval from a designated approver group.
Architecture Overview
The IDP runs as an ECS Fargate task behind an Application Load Balancer, accessible at idp.gabaltech.co.uk. Here's the high-level request flow:
Developer โ ALB โ ECS Fargate (Node.js/Express)
โ
Cognito (auth check)
โ
โโโโโโโโโโโโโโโโโโโโโโโโ
โ dev/test request? โ โ GitLab CI API trigger โ ECS rolling deploy
โ prod request? โ โ DynamoDB (pending_approval)
โโโโโโโโโโโโโโโโโโโโโโโโ
โ
Approver reviews โ Approve/Reject โ GitLab CI trigger
Authentication: Cognito Hosted UI with Invite-Only Access
The first zero-trust decision: no self-registration. The IDP is an internal tool โ if you're not already on the team, you shouldn't be able to create an account.
AWS Cognito supports this natively. We configured the User Pool with ADMIN_NO_SRP_AUTH and disabled the self-registration flow entirely. New users are created by an admin via the Cognito console or AWS CLI:
aws cognito-idp admin-create-user \
--user-pool-id eu-west-2_XXXXXXXX \
--username developer@company.com \
--temporary-password TempPass123! \
--user-attributes Name=email,Value=developer@company.com \
--message-action SUPPRESS
The Cognito Hosted UI handles the OAuth 2.0 authorization code flow. Our Express app uses passport-oauth2 to validate the JWT on each request. Because we're using Cognito's built-in hosted UI, we didn't have to build login/logout screens โ they come for free and are HTTPS-enforced by AWS.
Approver Group
Authorization (not just authentication) matters for production gating. We use a Cognito User Pool Group called approvers. After token validation, the middleware checks group membership:
function requireApprover(req, res, next) {
const groups = req.user?.['cognito:groups'] || [];
if (!groups.includes('approvers')) {
return res.status(403).render('error', { message: 'Approver access required' });
}
next();
}
This middleware is applied only to the /approvals routes. Everyone else sees the deployment request form, but the approve/reject buttons are gated.
Deployment Flow: DynamoDB as the State Machine
For dev and test environments, the IDP calls the GitLab CI API directly:
await axios.post(
`https://gitlab.com/api/v4/projects/${PROJECT_ID}/trigger/pipeline`,
{ token: GITLAB_TRIGGER_TOKEN, ref: 'develop', variables: { IMAGE_TAG: tag, SERVICE: service } }
);
For production, instead of triggering immediately, we write a record to DynamoDB with status pending_approval:
await ddb.put({
TableName: 'gabaltech-idp-deployments',
Item: {
deploymentId: crypto.randomUUID(),
service,
imageTag: tag,
environment: 'prod',
requestedBy: req.user.email,
reason,
status: 'pending_approval',
requestedAt: new Date().toISOString()
}
}).promise();
DynamoDB Table Design
The table uses a simple key structure:
- PK:
deploymentId(UUID) โ for direct lookups by ID - GSI:
environment-requestedAt-indexโ for listing pending production deployments chronologically
This design supports two query patterns efficiently:
- Get a specific deployment by ID (approver clicking an email link)
- List all pending production approvals, newest first
// List pending prod deployments
const result = await ddb.query({
TableName: 'gabaltech-idp-deployments',
IndexName: 'environment-requestedAt-index',
KeyConditionExpression: '#env = :env',
FilterExpression: '#status = :status',
ExpressionAttributeNames: { '#env': 'environment', '#status': 'status' },
ExpressionAttributeValues: { ':env': 'prod', ':status': 'pending_approval' },
ScanIndexForward: false // newest first
}).promise();
Zero-Trust Infrastructure Principles Applied
Least-Privilege IAM
The ECS task role has exactly the permissions it needs โ nothing more:
{
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:UpdateItem",
"dynamodb:Query"
],
"Resource": [
"arn:aws:dynamodb:eu-west-2:ACCOUNT:table/gabaltech-idp-deployments",
"arn:aws:dynamodb:eu-west-2:ACCOUNT:table/gabaltech-idp-deployments/index/*"
]
}
No dynamodb:Scan, no * resources, no GetSecretValue for things the task doesn't need.
Secrets Management
The GitLab trigger token is stored as a Pulumi secret, injected at deploy time as an ECS task definition environment variable sourced from SSM Parameter Store (SecureString). Developers never see this token โ it's not in the codebase, not in environment files, not in CI logs.
No Persistent Credentials
The ECS task authenticates to AWS via the task IAM role โ no access keys, no credential files. The role is assumed automatically via the EC2 metadata endpoint that Fargate exposes to containers.
What We Learned
Keep the approval UI dead simple. Approvers need to see: what is being deployed, to where, by whom, and why. A wall of JSON is not helpful. We render a simple table with a big green Approve and red Reject button.
Audit trail is a feature, not an afterthought. Every deployment record in DynamoDB has
requestedBy,approvedBy,requestedAt,approvedAt. You will need this log six months from now when someone asks why a breaking change went to prod on a Friday.Cognito Hosted UI is fine for internal tools. It's not pretty, but it's secure, it handles MFA, and it took zero UI effort.
DynamoDB PAY_PER_REQUEST is ideal for low-volume internal tooling. At our deployment frequency (~5-10 per week), the table costs essentially nothing.
The full infrastructure is defined in Pulumi TypeScript and lives alongside the application code. One pulumi up stands up the entire stack: ALB, ECS service, Cognito pool, DynamoDB table, Route53 record, and ACM certificate.