AWS Serverless Full-Stack · Lesson 7 of 7
Project: Serverless Client Portal
Project: Serverless Client Portal
Build a client portal where authenticated users manage their projects, upload files, and communicate — all on AWS serverless infrastructure. No servers to manage, scales to zero, and you only pay for actual usage.
Architecture Overview
[Browser / Frontend]
|
| HTTPS
▼
[API Gateway] ←→ [Cognito User Pool]
| (auth)
▼
[Lambda Functions]
├── auth-authorizer
├── projects-handler
├── files-handler
└── messages-handler
|
┌────┴──────────┐
▼ ▼
[DynamoDB] [S3 Bucket]
(data) (files)Project Setup with SAM
Bash
# Install AWS SAM CLI
pip install aws-sam-cli
# Create project
sam init --runtime dotnet8 --name client-portal
cd client-portalSAM Template
YAML
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Runtime: dotnet8
MemorySize: 512
Timeout: 30
Environment:
Variables:
TABLE_NAME: !Ref ClientPortalTable
BUCKET_NAME: !Ref FilesBucket
Resources:
# Cognito User Pool
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: ClientPortalUsers
AutoVerifiedAttributes: [email]
PasswordPolicy:
MinimumLength: 8
RequireNumbers: true
RequireSymbols: true
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
# DynamoDB table — single-table design
ClientPortalTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ClientPortal
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: PK, AttributeType: S }
- { AttributeName: SK, AttributeType: S }
- { AttributeName: GSI1PK, AttributeType: S }
KeySchema:
- { AttributeName: PK, KeyType: HASH }
- { AttributeName: SK, KeyType: RANGE }
GlobalSecondaryIndexes:
- IndexName: GSI1
KeySchema:
- { AttributeName: GSI1PK, KeyType: HASH }
- { AttributeName: SK, KeyType: RANGE }
Projection: { ProjectionType: ALL }
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
# S3 Bucket
FilesBucket:
Type: AWS::S3::Bucket
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
CorsConfiguration:
CorsRules:
- AllowedOrigins: ['*']
AllowedMethods: [PUT, GET]
AllowedHeaders: ['*']
# Lambda Functions
ProjectsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: ClientPortal.Lambda::ClientPortal.Lambda.ProjectsHandler::FunctionHandler
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ClientPortalTable
Events:
GetProjects:
Type: Api
Properties:
Path: /projects
Method: get
Auth:
Authorizer: CognitoAuthorizer
CreateProject:
Type: Api
Properties:
Path: /projects
Method: post
Auth:
Authorizer: CognitoAuthorizer
FilesFunction:
Type: AWS::Serverless::Function
Properties:
Handler: ClientPortal.Lambda::ClientPortal.Lambda.FilesHandler::FunctionHandler
Policies:
- S3CrudPolicy:
BucketName: !Ref FilesBucket
- DynamoDBCrudPolicy:
TableName: !Ref ClientPortalTable
Events:
GetUploadUrl:
Type: Api
Properties:
Path: /files/upload-url
Method: get
Auth:
Authorizer: CognitoAuthorizerDynamoDB Single-Table Design
Entity PK SK
──────────────────────────────────────────
User USER# PROFILE
Project PROJECT# METADATA
USER# PROJECT# (ownership index)
File PROJECT# FILE#
Message PROJECT# MSG## C#
// Models
public record ProjectItem
{
public string PK { get; init; } = ""; // PROJECT#<id>
public string SK { get; init; } = ""; // METADATA
public string GSI1PK { get; init; } = ""; // USER#<ownerId>
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string Status { get; init; } = "active";
public string OwnerId { get; init; } = "";
public string CreatedAt { get; init; } = "";
}Lambda Function — Projects Handler
C#
// ProjectsHandler.cs
public class ProjectsHandler
{
private readonly IAmazonDynamoDB _dynamo = new AmazonDynamoDBClient();
private readonly string _table = Environment.GetEnvironmentVariable("TABLE_NAME")!;
public async Task<APIGatewayProxyResponse> FunctionHandler(
APIGatewayProxyRequest request,
ILambdaContext context)
{
var userId = request.RequestContext.Authorizer["claims"]?["sub"]?.ToString();
if (userId is null) return Unauthorized();
return request.HttpMethod switch
{
"GET" => await GetProjectsAsync(userId),
"POST" => await CreateProjectAsync(userId, request.Body),
_ => MethodNotAllowed()
};
}
private async Task<APIGatewayProxyResponse> GetProjectsAsync(string userId)
{
var client = new DynamoDBContext(_dynamo);
// Query by owner using GSI1
var config = new DynamoDBOperationConfig
{
IndexName = "GSI1",
QueryFilter = new List<ScanCondition>
{
new("GSI1PK", ScanOperator.Equal, $"USER#{userId}")
}
};
var projects = await client
.QueryAsync<ProjectItem>($"USER#{userId}", config)
.GetRemainingAsync();
return Ok(projects);
}
private async Task<APIGatewayProxyResponse> CreateProjectAsync(string userId, string body)
{
var req = JsonSerializer.Deserialize<CreateProjectRequest>(body)!;
var id = Guid.NewGuid().ToString();
var client = new DynamoDBContext(_dynamo);
var project = new ProjectItem
{
PK = $"PROJECT#{id}",
SK = "METADATA",
GSI1PK = $"USER#{userId}",
Id = id,
Name = req.Name,
OwnerId = userId,
CreatedAt = DateTimeOffset.UtcNow.ToString("O")
};
await client.SaveAsync(project);
return Created(project);
}
private static APIGatewayProxyResponse Ok(object body) =>
new() { StatusCode = 200, Body = JsonSerializer.Serialize(body),
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" } };
private static APIGatewayProxyResponse Created(object body) =>
new() { StatusCode = 201, Body = JsonSerializer.Serialize(body),
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" } };
private static APIGatewayProxyResponse Unauthorized() =>
new() { StatusCode = 401, Body = "{\"error\":\"Unauthorized\"}" };
private static APIGatewayProxyResponse MethodNotAllowed() =>
new() { StatusCode = 405, Body = "{\"error\":\"Method not allowed\"}" };
}Files Handler — Pre-signed S3 URLs
C#
public class FilesHandler
{
private readonly IAmazonS3 _s3 = new AmazonS3Client();
private readonly string _bucket = Environment.GetEnvironmentVariable("BUCKET_NAME")!;
public async Task<APIGatewayProxyResponse> FunctionHandler(
APIGatewayProxyRequest request, ILambdaContext context)
{
var userId = request.RequestContext.Authorizer["claims"]?["sub"]?.ToString();
var projectId = request.QueryStringParameters?["projectId"];
var fileName = request.QueryStringParameters?["fileName"];
if (userId is null || projectId is null || fileName is null)
return new() { StatusCode = 400, Body = "{\"error\":\"Missing parameters\"}" };
var key = $"{userId}/{projectId}/{fileName}";
var expiry = DateTime.UtcNow.AddMinutes(15);
var presigned = new GetPreSignedUrlRequest
{
BucketName = _bucket,
Key = key,
Verb = HttpVerb.PUT,
Expires = expiry,
ContentType = "application/octet-stream"
};
var url = await _s3.GetPreSignedURLAsync(presigned);
return new()
{
StatusCode = 200,
Body = JsonSerializer.Serialize(new { uploadUrl = url, key }),
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" }
};
}
}Deploy and Test
Bash
# Build and deploy
sam build
sam deploy --guided
# Test with curl
TOKEN=$(aws cognito-idp initiate-auth \
--auth-flow USER_PASSWORD_AUTH \
--client-id <UserPoolClientId> \
--auth-parameters USERNAME=test@example.com,PASSWORD=Test1234! \
--query 'AuthenticationResult.IdToken' --output text)
curl -H "Authorization: Bearer $TOKEN" \
https://<api-id>.execute-api.eu-west-1.amazonaws.com/Prod/projects
# Local testing
sam local start-apiKey Takeaways
- SAM template defines all infrastructure as code — the entire portal deploys with
sam deploy - Cognito handles auth complexity — user pools, tokens, and JWT validation without custom code
- Single-table DynamoDB design with a GSI lets you query by user or project efficiently
- Pre-signed S3 URLs let clients upload directly to S3 — Lambda never handles file bytes
- PAY_PER_REQUEST billing on DynamoDB and Lambda means zero cost when idle — perfect for portals with unpredictable traffic