Spis treści 13 sekcji
  1. SaaS na AWS - da się tanio?
  2. Architektura - przegląd systemu
  3. Warstwa 1: Frontend na S3 + CloudFront
  4. Warstwa 2: Autentykacja z Cognito
  5. Warstwa 3: API na Lambda + API Gateway
  6. Warstwa 4: Multi-tenant DynamoDB
  7. Warstwa 5: Pliki użytkowników (S3 + presigned URLs)
  8. Rozbicie kosztów - ile to naprawdę kosztuje?
  9. Billing i plany subskrypcyjne
  10. Deployment z GitHub Actions
  11. Skalowanie - co gdy ruch rośnie?
  12. FAQ - najczęstsze pytania o SaaS na AWS
  13. Co dalej?
TL;DR: Budowa SaaS na AWS nie wymaga tysięcy złotych miesięcznie. Architektura serverless (Lambda + API Gateway + DynamoDB + S3 + CloudFront) pozwala utrzymać aplikację obsługującą tysiące użytkowników za ~160-190 zł/mc. W tym artykule pokażę kompletną architekturę, rozbicie kosztów i gotową infrastrukturę do wdrożenia.

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:

WarstwaSerwis AWSRola
Frontend (SPA)S3 + CloudFrontHosting statycznych plików (React/Vue/Next.js)
AutentykacjaCognitoRejestracja, logowanie, JWT tokeny
API BackendAPI Gateway + LambdaLogika biznesowa (REST/GraphQL)
Baza danychDynamoDBDane użytkowników, tenantów, konfiguracji
StorageS3Pliki użytkowników (avatary, dokumenty)
EmailSESEmaile transakcyjne (potwierdzenia, reset hasła)

Przepływ requestu

  1. Użytkownik wchodzi na app.twojsaas.pl → CloudFront serwuje SPA z S3
  2. Logowanie → Cognito weryfikuje credentials, zwraca JWT token
  3. Request API → API Gateway waliduje JWT → Lambda przetwarza logikę → DynamoDB read/write
  4. 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.

  1. Utwórz bucket S3: twojsaas-frontend (Block all public access = ON)
  2. Wejdź w CloudFront → Create distribution
  3. Origin: wybierz bucket S3, włącz Origin Access Control (OAC)
  4. Default root object: index.html
  5. Custom error response: 403 → /index.html (200) - obsługa SPA routing
  6. Price class: Use only North America and Europe (tańszy)
  7. W Alternate domain dodaj app.twojsaas.pl i SSL z ACM
  8. Skopiuj bucket policy z panelu CloudFront i dodaj do S3
Screenshot: CloudFront distribution z S3 origin i OAC
# 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.

  1. Wejdź w Cognito → Create user pool
  2. Sign-in: Email (najprostsze dla SaaS)
  3. Password policy: minimum 8 znaków, wymóg wielkich liter i cyfr
  4. MFA: Optional (użytkownicy mogą włączyć sami)
  5. Self-service sign-up: Enable
  6. Email: użyj Cognito default na start (później przełącz na SES)
  7. App client: typ Public client, nazwa twojsaas-web
  8. Hosted UI domain: twojsaas.auth.eu-central-1.amazoncognito.com
  9. Callback URL: https://app.twojsaas.pl/callback
Screenshot: Cognito User Pool - konfiguracja sign-up i email
# 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
    });
  });
}
Koszt Cognito: W tierze Essentials (domyślnym dla nowych user pooli) pierwsze 10,000 MAU (Monthly Active Users) za darmo. Powyżej: $0.015/MAU. Dla 10,000 użytkowników SaaS - $0/mc. Trudno o tańszą autentykację.

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#abc123PROFILEnazwa firmy, plan, created_at
TENANT#abc123USER#user1email, rola, ostatnie logowanie
TENANT#abc123USER#user2email, rola, ostatnie logowanie
TENANT#abc123PROJECT#proj1nazwa projektu, status, created_by
TENANT#abc123PROJECT#proj2nazwa projektu, status, created_by
TENANT#xyz789PROFILEinna firma, inny plan
TENANT#xyz789USER#user3email, 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
Bezpieczeństwo: Zawsze wyciągaj tenant_id z JWT tokena (Cognito claims), nigdy z parametrów URL. Użytkownik mógłby podmienić tenant_id w URL i zobaczyć cudze dane. JWT jest podpisany przez Cognito i nie da się go sfałszować. Więcej o bezpieczeństwie w poradniku IAM.

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.

SerwisUżycieKoszt/mc
CloudFront50 GB transfer, 2M requestów~$6 (~24 zł)
S3 (frontend)100 MB static files~$0.01
S3 (user files)10 GB storage~$0.23
Cognito2,000 MAU$0 (free do 10K)
API Gateway500K requestów~$1.75 (~7 zł)
Lambda500K invocations × 200ms × 256MB~$1.30 (~5 zł)
DynamoDBOn-demand, 5 GB, 100K odczytów + 50K zapisów/mc~$5 (~20 zł)
SES10K emaili/mc~$1 (~4 zł)
Route 531 hosted zone + queries~$0.50 (~2 zł)
ACM (SSL)Certyfikat$0 (free)
CloudWatchLogi + alarmy basic~$3 (~12 zł)
SUMA~$19 (~76 zł)
76 zł, nie 200 zł? Przy 2,000 użytkowników to faktycznie tak tanio. Koszt rośnie z ruchem. Przy 10,000 MAU i 2.5M requestów/mc koszt wzrośnie do ~$40-50 (~160-200 zł). To wciąż śmiesznie mało jak na SaaS. Więcej o kalkulowaniu kosztów w artykule o kosztach AWS.

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ógProblemRozwiązanie
1,000 równoległych LambdaDomyślny limit concurrency w regionieRequest limit increase przez Support
40,000 RCU/WCUDynamoDB throttlingProvisioned capacity + auto-scaling lub DAX cache
10,000 requestów/s API GWAPI Gateway throttlingRequest limit increase
Wiele regionówLatency dla globalnych userówDynamoDB Global Tables + multi-region Lambda
Ważne: Te limity dotyczą naprawdę dużego ruchu. Dla SaaS z 10,000 MAU nie zbliżysz się do żadnego z nich. Optymalizuj dopiero gdy metryki w CloudWatch pokażą, że się zbliżasz.

FAQ - najczęstsze pytania o SaaS na AWS

Czy serverless nadaje się do każdego SaaS?

Nie. Serverless świetnie sprawdza się przy API-heavy SaaS z nieregularnym ruchem. Jeśli Twój SaaS wymaga stałego przetwarzania (np. video encoding, ML training), kontenery na ECS/Fargate będą tańsze i prostsze. Porównanie znajdziesz w artykule o Docker na AWS.

Dlaczego DynamoDB a nie PostgreSQL (RDS)?

DynamoDB on-demand kosztuje $0 przy niskim ruchu (pay-per-request). Najtańsza instancja RDS to ~$15/mc. Dla startupu, który nie wie czy SaaS wystrzeli, DynamoDB = zero kosztów stałych. Wadą jest brak SQL i trudniejsze zapytania analityczne.

Jak obsłużyć multi-tenancy na poziomie subdomeny (klient1.app.pl)?

CloudFront z wildcard SSL (*.app.pl) + Lambda@Edge do parsowania subdomeny i mapowania na tenant_id. Albo prościej: jeden frontend, tenant_id w URL path (/t/abc123/) lub w JWT custom claim.

Czy 200 zł/mc to rachunek z VAT?

AWS fakturuje w USD netto. 200 zł to ~$50 USD, co pokrywa SaaS z 10K+ MAU. Pamiętaj o doliczeniu 23% VAT przy fakturze polskiej firmy, chyba że masz reverse charge (VAT-EU). Szczegóły kosztów w artykule o cenniku AWS.

Co dalej?

  1. Zacznij od frontendu na S3 + CloudFront i Cognito - to fundament.
  2. Dodaj pierwszy endpoint Lambda + API Gateway z autoryzacją Cognito.
  3. Zaprojektuj schemat DynamoDB z single-table design i tenant isolation.
  4. Podepnij Stripe do billingowania i webhooków.
  5. Skonfiguruj CI/CD dla automatycznych deployów.
  6. 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.

Emil Kowalczyk

Pasjonat chmury i twórca CloudManiak.pl. Na co dzień MSP Engineer w amerykańskiej firmie ClearScale (AWS Premier Tier Partner). Pomagam osobom wchodzącym do świata chmury zdobywać wiedzę i certyfikaty.