Spis treści 13 sekcji
- SaaS na AWS - da się tanio?
- Architektura - przegląd systemu
- Warstwa 1: Frontend na S3 + CloudFront
- Warstwa 2: Autentykacja z Cognito
- Warstwa 3: API na Lambda + API Gateway
- Warstwa 4: Multi-tenant DynamoDB
- Warstwa 5: Pliki użytkowników (S3 + presigned URLs)
- Rozbicie kosztów - ile to naprawdę kosztuje?
- Billing i plany subskrypcyjne
- Deployment z GitHub Actions
- Skalowanie - co gdy ruch rośnie?
- FAQ - najczęstsze pytania o SaaS na AWS
- Co dalej?
SaaS na AWS - da się tanio?
Większość artykułów o budowie SaaS zakłada budżet enterprise: klaster Kubernetes, dedykowane bazy danych, load balancery. Dla indie developera lub małego startupu to nonsens. Pierwszy klient nie generuje ruchu na poziomie Netflixa.
Pokażę architekturę SaaS, która:
- Obsługuje do 10,000 MAU (Monthly Active Users) bez zmian
- Kosztuje poniżej 200 zł/mc (przy umiarkowanym ruchu)
- Skaluje się automatycznie gdy ruch rośnie
- Nie wymaga zarządzania serwerami (100% serverless)
- Ma wbudowaną autentykację, API, bazę danych i CDN
Jeśli dopiero zaczynasz z AWS, sprawdź najpierw co jest za darmo w Free Tier - część z tych serwisów ma stałe darmowe limity (always free), a nowe konta dostają do $200 kredytów na start.
Architektura - przegląd systemu
Nasza aplikacja SaaS składa się z pięciu warstw:
| Warstwa | Serwis AWS | Rola |
|---|---|---|
| Frontend (SPA) | S3 + CloudFront | Hosting statycznych plików (React/Vue/Next.js) |
| Autentykacja | Cognito | Rejestracja, logowanie, JWT tokeny |
| API Backend | API Gateway + Lambda | Logika biznesowa (REST/GraphQL) |
| Baza danych | DynamoDB | Dane użytkowników, tenantów, konfiguracji |
| Storage | S3 | Pliki użytkowników (avatary, dokumenty) |
| SES | Emaile transakcyjne (potwierdzenia, reset hasła) |
Przepływ requestu
- Użytkownik wchodzi na
app.twojsaas.pl→ CloudFront serwuje SPA z S3 - Logowanie → Cognito weryfikuje credentials, zwraca JWT token
- Request API → API Gateway waliduje JWT → Lambda przetwarza logikę → DynamoDB read/write
- Upload pliku → presigned URL z S3 (bezpośredni upload z przeglądarki)
Warstwa 1: Frontend na S3 + CloudFront
Single Page Application (React, Vue, Svelte, Next.js static export) hostowana na S3 z CloudFront jako CDN. Zero serwerów, zero Nginx, zero patching.
- Utwórz bucket S3:
twojsaas-frontend(Block all public access = ON) - Wejdź w CloudFront → Create distribution
- Origin: wybierz bucket S3, włącz Origin Access Control (OAC)
- Default root object:
index.html - Custom error response: 403 →
/index.html(200) - obsługa SPA routing - Price class: Use only North America and Europe (tańszy)
- W Alternate domain dodaj
app.twojsaas.pli SSL z ACM - Skopiuj bucket policy z panelu CloudFront i dodaj do S3
# 1. Utwórz bucket
aws s3 mb s3://twojsaas-frontend --region eu-central-1
# 2. Zablokuj publiczny dostęp
aws s3api put-public-access-block \
--bucket twojsaas-frontend \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
# 3. Utwórz OAC
aws cloudfront create-origin-access-control \
--origin-access-control-config \
Name=twojsaas-oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3
# 4. Utwórz CloudFront distribution
aws cloudfront create-distribution \
--distribution-config '{
"Origins": {"Items": [{"Id": "S3", "DomainName": "twojsaas-frontend.s3.eu-central-1.amazonaws.com",
"S3OriginConfig": {"OriginAccessIdentity": ""},
"OriginAccessControlId": "EXXXX"}], "Quantity": 1},
"DefaultCacheBehavior": {
"TargetOriginId": "S3",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true
},
"DefaultRootObject": "index.html",
"Enabled": true,
"Comment": "TwojSaaS Frontend",
"CallerReference": "twojsaas-2026"
}'
# 5. Deploy plików
npm run build
aws s3 sync ./dist s3://twojsaas-frontend --delete
Szczegółowy poradnik S3 hosting znajdziesz w artykule o S3.
Warstwa 2: Autentykacja z Cognito
Amazon Cognito to managed auth service - rejestracja, logowanie, MFA, social login (Google, Facebook), password reset. Wszystko out of the box, bez pisania własnego systemu logowania.
- Wejdź w Cognito → Create user pool
- Sign-in: Email (najprostsze dla SaaS)
- Password policy: minimum 8 znaków, wymóg wielkich liter i cyfr
- MFA: Optional (użytkownicy mogą włączyć sami)
- Self-service sign-up: Enable
- Email: użyj Cognito default na start (później przełącz na SES)
- App client: typ Public client, nazwa
twojsaas-web - Hosted UI domain:
twojsaas.auth.eu-central-1.amazoncognito.com - Callback URL:
https://app.twojsaas.pl/callback
# 1. Utwórz User Pool
aws cognito-idp create-user-pool \
--pool-name "TwojSaaS" \
--auto-verified-attributes email \
--username-attributes email \
--policies '{"PasswordPolicy":{"MinimumLength":8,"RequireUppercase":true,"RequireLowercase":true,"RequireNumbers":true,"RequireSymbols":false}}' \
--schema '[{"Name":"email","Required":true,"Mutable":true},{"Name":"name","Required":false,"Mutable":true}]'
# Zanotuj UserPoolId z odpowiedzi
# 2. Utwórz App Client
aws cognito-idp create-user-pool-client \
--user-pool-id eu-central-1_XXXXXX \
--client-name "twojsaas-web" \
--no-generate-secret \
--explicit-auth-flows ALLOW_USER_SRP_AUTH ALLOW_REFRESH_TOKEN_AUTH \
--supported-identity-providers COGNITO \
--callback-urls '["https://app.twojsaas.pl/callback"]' \
--logout-urls '["https://app.twojsaas.pl/logout"]'
# 3. Skonfiguruj domene hosted UI
aws cognito-idp create-user-pool-domain \
--user-pool-id eu-central-1_XXXXXX \
--domain twojsaas
Na frontendzie używasz biblioteki aws-amplify lub amazon-cognito-identity-js:
// React - logowanie z Cognito
import { CognitoUserPool, AuthenticationDetails, CognitoUser } from 'amazon-cognito-identity-js';
const poolData = {
UserPoolId: 'eu-central-1_XXXXXX',
ClientId: 'your-client-id'
};
const userPool = new CognitoUserPool(poolData);
function login(email, password) {
const authDetails = new AuthenticationDetails({
Username: email,
Password: password
});
const cognitoUser = new CognitoUser({
Username: email,
Pool: userPool
});
return new Promise((resolve, reject) => {
cognitoUser.authenticateUser(authDetails, {
onSuccess: (result) => {
const token = result.getIdToken().getJwtToken();
// Ten token wysyłasz w header Authorization: Bearer {token}
resolve(token);
},
onFailure: reject
});
});
}
Warstwa 3: API na Lambda + API Gateway
Backend to funkcje Lambda za API Gateway. Każdy endpoint to osobna funkcja (lub kilka endpointów w jednej funkcji z routingiem).
Dla SaaS rekomendacja to podejście "monolith Lambda" - jedna funkcja obsługująca wiele tras. Mniej cold startów, prostsza konfiguracja:
# handler.py - monolith Lambda z routingiem
import json
import boto3
from decimal import Decimal
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('TwojSaaS')
class DecimalEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Decimal):
return str(obj)
return super().default(obj)
def handler(event, context):
method = event['httpMethod']
path = event['resource']
user_id = event['requestContext']['authorizer']['claims']['sub']
tenant_id = event['requestContext']['authorizer']['claims'].get('custom:tenant_id', user_id)
# Routing
routes = {
('GET', '/api/projects'): list_projects,
('POST', '/api/projects'): create_project,
('GET', '/api/projects/{id}'): get_project,
('PUT', '/api/projects/{id}'): update_project,
('DELETE', '/api/projects/{id}'): delete_project,
('GET', '/api/billing'): get_billing,
}
handler_fn = routes.get((method, path))
if not handler_fn:
return response(404, {'error': 'Not found'})
try:
return handler_fn(event, user_id, tenant_id)
except Exception as e:
print(f"Error: {e}")
return response(500, {'error': 'Internal server error'})
def list_projects(event, user_id, tenant_id):
result = table.query(
KeyConditionExpression='PK = :pk AND begins_with(SK, :sk)',
ExpressionAttributeValues={
':pk': f'TENANT#{tenant_id}',
':sk': 'PROJECT#'
}
)
return response(200, result['Items'])
def create_project(event, user_id, tenant_id):
body = json.loads(event['body'])
import uuid
project_id = str(uuid.uuid4())
item = {
'PK': f'TENANT#{tenant_id}',
'SK': f'PROJECT#{project_id}',
'name': body['name'],
'created_by': user_id,
'status': 'active'
}
table.put_item(Item=item)
return response(201, item)
def response(status_code, body):
return {
'statusCode': status_code,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'https://app.twojsaas.pl'
},
'body': json.dumps(body, cls=DecimalEncoder)
}
API Gateway automatycznie waliduje JWT z Cognito - nie musisz pisać middleware auth:
# Cognito Authorizer na API Gateway (CLI)
aws apigateway create-authorizer \
--rest-api-id abc123 \
--name CognitoAuth \
--type COGNITO_USER_POOLS \
--identity-source "method.request.header.Authorization" \
--provider-arns arn:aws:cognito-idp:eu-central-1:123456789:userpool/eu-central-1_XXXXXX
Warstwa 4: Multi-tenant DynamoDB
Kluczowe pytanie w SaaS: jak izolować dane tenantów (klientów)? W DynamoDB najlepsze podejście to single-table design z tenant ID w partition key:
| PK (Partition Key) | SK (Sort Key) | Dane |
|---|---|---|
| TENANT#abc123 | PROFILE | nazwa firmy, plan, created_at |
| TENANT#abc123 | USER#user1 | email, rola, ostatnie logowanie |
| TENANT#abc123 | USER#user2 | email, rola, ostatnie logowanie |
| TENANT#abc123 | PROJECT#proj1 | nazwa projektu, status, created_by |
| TENANT#abc123 | PROJECT#proj2 | nazwa projektu, status, created_by |
| TENANT#xyz789 | PROFILE | inna firma, inny plan |
| TENANT#xyz789 | USER#user3 | email, rola |
Taki design zapewnia:
- Izolację danych - query na PK=TENANT#abc123 nigdy nie zwróci danych innego tenanta
- Wydajność - jeden query pobiera wszystkie dane tenanta
- Niski koszt - jedna tabela, brak RDS per tenant
Warstwa 5: Pliki użytkowników (S3 + presigned URLs)
Użytkownicy SaaS uploadują pliki (avatary, dokumenty, raporty). Zamiast przepuszczać je przez Lambda (limit 6MB), używasz presigned URLs - Lambda generuje URL, a przeglądarka uploaduje bezpośrednio do S3:
# Lambda endpoint generujący presigned URL
import json
import boto3
import uuid
s3 = boto3.client('s3')
BUCKET = 'twojsaas-user-files'
def generate_upload_url(event, user_id, tenant_id):
body = json.loads(event['body'])
file_key = f'{tenant_id}/{uuid.uuid4()}/{body["filename"]}'
url = s3.generate_presigned_url(
'put_object',
Params={
'Bucket': BUCKET,
'Key': file_key,
'ContentType': body.get('content_type', 'application/octet-stream'),
},
ExpiresIn=300 # URL ważny 5 minut
)
return response(200, {'upload_url': url, 'file_key': file_key})
Szczegółowy opis presigned URLs znajdziesz w artykule o S3.
Rozbicie kosztów - ile to naprawdę kosztuje?
Scenariusz: SaaS z 2,000 aktywnych użytkowników, 500K requestów API/mc, 5 GB danych w DynamoDB, 10 GB plików w S3.
| Serwis | Użycie | Koszt/mc |
|---|---|---|
| CloudFront | 50 GB transfer, 2M requestów | ~$6 (~24 zł) |
| S3 (frontend) | 100 MB static files | ~$0.01 |
| S3 (user files) | 10 GB storage | ~$0.23 |
| Cognito | 2,000 MAU | $0 (free do 10K) |
| API Gateway | 500K requestów | ~$1.75 (~7 zł) |
| Lambda | 500K invocations × 200ms × 256MB | ~$1.30 (~5 zł) |
| DynamoDB | On-demand, 5 GB, 100K odczytów + 50K zapisów/mc | ~$5 (~20 zł) |
| SES | 10K emaili/mc | ~$1 (~4 zł) |
| Route 53 | 1 hosted zone + queries | ~$0.50 (~2 zł) |
| ACM (SSL) | Certyfikat | $0 (free) |
| CloudWatch | Logi + alarmy basic | ~$3 (~12 zł) |
| SUMA | ~$19 (~76 zł) |
Billing i plany subskrypcyjne
SaaS potrzebuje systemu billingowego. Najpopularniejsze podejście: Stripe do płatności + DynamoDB do śledzenia limitów. Uwaga: endpoint webhooka wystaw bez autoryzatora Cognito (Stripe nie wysyła JWT) - bezpieczeństwo zapewnia weryfikacja nagłówka Stripe-Signature:
# Webhook Stripe → Lambda
# Stripe wysyła event gdy klient płaci/anuluje
def stripe_webhook(event, user_id, tenant_id):
import stripe
stripe.api_key = get_secret('stripe_key')
payload = event['body']
sig_header = event['headers'].get('Stripe-Signature')
webhook_secret = get_secret('stripe_webhook_secret')
stripe_event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
if stripe_event['type'] == 'checkout.session.completed':
session = stripe_event['data']['object']
tenant_id = session['client_reference_id']
# Aktywuj plan w DynamoDB
table.update_item(
Key={'PK': f'TENANT#{tenant_id}', 'SK': 'PROFILE'},
UpdateExpression='SET plan_name = :plan, plan_active = :active',
ExpressionAttributeValues={
':plan': session['metadata']['plan'],
':active': True
}
)
elif stripe_event['type'] == 'customer.subscription.deleted':
# Deaktywuj plan
customer_id = stripe_event['data']['object']['customer']
# ... obsługa anulowania
return response(200, {'received': True})
Deployment z GitHub Actions
Automatyczny deploy całego stacku przy każdym pushu do main:
# .github/workflows/deploy-saas.yml
name: Deploy SaaS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: eu-central-1
- name: Package & deploy Lambda
run: |
cd backend
pip install -r requirements.txt -t ./package
cd package && zip -r ../../function.zip .
cd .. && zip -g ../function.zip handler.py
aws lambda update-function-code \
--function-name twojsaas-api \
--zip-file fileb://../function.zip --publish
deploy-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: cd frontend && npm ci && npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: eu-central-1
- run: |
aws s3 sync frontend/dist s3://twojsaas-frontend --delete
aws cloudfront create-invalidation \
--distribution-id E1234567890 --paths "/*"
Pełny poradnik CI/CD znajdziesz w artykule o CodePipeline vs GitHub Actions.
Skalowanie - co gdy ruch rośnie?
Architektura serverless skaluje się automatycznie, ale jest kilka progów do świadomego zarządzania:
| Próg | Problem | Rozwiązanie |
|---|---|---|
| 1,000 równoległych Lambda | Domyślny limit concurrency w regionie | Request limit increase przez Support |
| 40,000 RCU/WCU | DynamoDB throttling | Provisioned capacity + auto-scaling lub DAX cache |
| 10,000 requestów/s API GW | API Gateway throttling | Request limit increase |
| Wiele regionów | Latency dla globalnych userów | DynamoDB Global Tables + multi-region Lambda |
FAQ - najczęstsze pytania o SaaS na AWS
Czy serverless nadaje się do każdego SaaS?
Dlaczego DynamoDB a nie PostgreSQL (RDS)?
Jak obsłużyć multi-tenancy na poziomie subdomeny (klient1.app.pl)?
Czy 200 zł/mc to rachunek z VAT?
Co dalej?
- Zacznij od frontendu na S3 + CloudFront i Cognito - to fundament.
- Dodaj pierwszy endpoint Lambda + API Gateway z autoryzacją Cognito.
- Zaprojektuj schemat DynamoDB z single-table design i tenant isolation.
- Podepnij Stripe do billingowania i webhooków.
- Skonfiguruj CI/CD dla automatycznych deployów.
- Dodaj monitoring CloudWatch i alarmy na błędy/koszty.
Twój SaaS potrzebuje systemu powiadomień? Sprawdź System powiadomień z SNS + SQS + Lambda - idealny do alertów, onboardingu i komunikacji z użytkownikami.
SaaS na AWS za mniej niż 200 zł to nie teoria - to realna architektura, która skaluje się od pierwszego użytkownika do tysięcy. Zamiast przepłacać za VPS i samodzielnie konfigurować serwery, pozwól AWS zarządzać infrastrukturą, a Ty skup się na produkcie.
