Module 6: Cloud Migration Patterns
2. Environment Abstraction
Overview
In this capsule you'll learn the most fundamental pattern of cloud migration: environment abstraction. It's the ability for your code to interact with LocalStack or AWS without knowing which is which. The code doesn't ask "am I in development or production?" — it receives configuration and runs. The difference between a junior developer who hardcodes endpoint_url="http://localhost:4566" and a senior engineer who abstracts the environment is exactly this pattern.
Context: In Modules 4 and 5, your code has endpoint_url hardcoded in some files and absent in others. Switching from LocalStack to AWS requires editing lines of code. That works for a personal project, but in an engineering team with 3+ environments (dev, staging, prod), it doesn't scale. A mistake in one endpoint_url connects you to the wrong environment — and you write test data into production. Environment abstraction eliminates that class of errors by design.
The Problem: Code Coupled to the Environment
Real case: the M4 and M5 code
Look at how boto3 clients are created in the previous modules:
# Module 4 — code coupled to LocalStack
import boto3
s3 = boto3.client(
"s3",
endpoint_url="http://localhost:4566",
aws_access_key_id="test",
aws_secret_access_key="test",
region_name="us-east-1",
)
bucket = "ai-assets-dev"
s3.put_object(Bucket=bucket, Key="test.txt", Body=b"hello")
# Module 5 — code coupled to AWS
import boto3
s3 = boto3.client("s3") # Uses ~/.aws/credentials
bucket = "ai-assets-123456789012"
s3.put_object(Bucket=bucket, Key="test.txt", Body=b"hello")
Visible problem: these are two different files to do the same thing against two environments. If you want to run the M4 tests against AWS, you need to rewrite the client creation. If you want to test the M5 code on LocalStack, you need to add endpoint_url.
The 4 elements that change between environments
┌─────────────────────────────────────────────────────┐
│ What changes between environments │
├──────────────────────┬───────────────────────────────┤
│ 1. Endpoint URL │ localhost:4566 vs AWS default │
│ 2. Credentials │ test/test vs IAM roles │
│ 3. Resource names │ ai-dev vs ai-prod-123456 │
│ 4. Features │ SageMaker not on LocalStack │
└──────────────────────┴───────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ What does NOT change between environments │
├─────────────────────────────────────────────────────┤
│ - Business logic (process document, invoke LLM) │
│ - S3 key structure (prompts/, documents/, etc.) │
│ - Request/response format │
│ - Error handling │
│ - Functional tests │
└─────────────────────────────────────────────────────┘
The rule is simple: what changes between environments goes in configuration. What doesn't change goes in code.
The Pattern: Environment Abstraction
Level 1 — Simple environment variable
The first level of abstraction is an ENVIRONMENT variable that decides how to create the clients:
import boto3
import os
def get_s3_client():
"""Creates an S3 client configured for the current environment."""
environment = os.environ.get("ENVIRONMENT", "local")
if environment == "local":
return boto3.client(
"s3",
endpoint_url="http://localhost:4566",
aws_access_key_id="test",
aws_secret_access_key="test",
region_name="us-east-1",
)
# AWS (staging or production)
return boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
This works, but it has problems:
- ❌ The
if/elsegrows with every new environment - ❌ The values are hardcoded inside the function
- ❌ There's no validation that
ENVIRONMENTis a valid value - ❌ It's not testable (you can't inject configuration)
Level 2 — Config dict with endpoint switching
A step better: the configuration lives in a dictionary and the client is created from it:
import boto3
import os
from typing import Optional
ENVIRONMENT_CONFIGS = {
"local": {
"endpoint_url": "http://localhost:4566",
"aws_access_key_id": "test",
"aws_secret_access_key": "test",
"region_name": "us-east-1",
"s3_bucket": "ai-assets-local",
},
"staging": {
"endpoint_url": None,
"aws_access_key_id": None, # Uses IAM role
"aws_secret_access_key": None,
"region_name": "us-east-1",
"s3_bucket": "ai-assets-staging-123456",
},
"production": {
"endpoint_url": None,
"aws_access_key_id": None,
"aws_secret_access_key": None,
"region_name": "us-east-1",
"s3_bucket": "ai-assets-prod-123456",
},
}
def get_environment_config(env_name: Optional[str] = None) -> dict:
"""Returns the configuration for the specified environment."""
env = env_name or os.environ.get("ENVIRONMENT", "local")
if env not in ENVIRONMENT_CONFIGS:
raise ValueError(
f"Environment '{env}' not recognized. "
f"Options: {list(ENVIRONMENT_CONFIGS.keys())}"
)
return ENVIRONMENT_CONFIGS[env]
def create_s3_client(config: dict):
"""Creates an S3 client from a config dict."""
client_kwargs = {"region_name": config["region_name"]}
if config.get("endpoint_url"):
client_kwargs["endpoint_url"] = config["endpoint_url"]
if config.get("aws_access_key_id"):
client_kwargs["aws_access_key_id"] = config["aws_access_key_id"]
client_kwargs["aws_secret_access_key"] = config["aws_secret_access_key"]
return boto3.client("s3", **client_kwargs)
Improvements:
- ✅ The values aren't in the client-creation code
- ✅ Adding a new environment is adding an entry to the dict
- ✅ The client is created from config, not from logic
But it still has problems:
- ❌ The config is in the source code (not in .env files)
- ❌ There's no typing or validation
- ❌ The secrets are visible in the dict
Level 3 — The complete pattern (what you'll build)
The mature pattern uses Pydantic Settings to type and validate, loads from .env files, and a factory creates clients:
from pydantic_settings import BaseSettings
from typing import Optional
import boto3
class EnvironmentSettings(BaseSettings):
"""Typed environment configuration."""
environment: str = "local"
aws_region: str = "us-east-1"
aws_endpoint_url: Optional[str] = None
aws_access_key_id: Optional[str] = None
aws_secret_access_key: Optional[str] = None
s3_bucket: str = "ai-assets-local"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
def create_boto3_client(service: str, settings: EnvironmentSettings):
"""Creates a boto3 client configured according to the environment."""
kwargs = {"region_name": settings.aws_region}
if settings.aws_endpoint_url:
kwargs["endpoint_url"] = settings.aws_endpoint_url
if settings.aws_access_key_id:
kwargs["aws_access_key_id"] = settings.aws_access_key_id
kwargs["aws_secret_access_key"] = settings.aws_secret_access_key
return boto3.client(service, **kwargs)
With per-environment .env files:
# .env.local
ENVIRONMENT=local
AWS_ENDPOINT_URL=http://localhost:4566
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
AWS_REGION=us-east-1
S3_BUCKET=ai-assets-local
# .env.staging
ENVIRONMENT=staging
AWS_REGION=us-east-1
S3_BUCKET=ai-assets-staging-123456
# Credentials via IAM role, not in .env
# .env.production
ENVIRONMENT=production
AWS_REGION=us-east-1
S3_BUCKET=ai-assets-prod-123456
# Credentials via IAM role, not in .env
Now the business code is completely agnostic:
settings = EnvironmentSettings() # Reads from .env
s3 = create_boto3_client("s3", settings)
def process_document(doc_key: str) -> dict:
"""Processes a document — works in any environment."""
response = s3.get_object(Bucket=settings.s3_bucket, Key=doc_key)
content = response["Body"].read().decode("utf-8")
# ... identical business logic ...
return {"status": "processed", "key": doc_key}
Step-by-Step Implementation
Step 1: Create the configuration class
"""config/environment.py — Environment configuration."""
from pydantic_settings import BaseSettings
from pydantic import field_validator
from typing import Optional
from enum import Enum
class EnvironmentName(str, Enum):
LOCAL = "local"
STAGING = "staging"
PRODUCTION = "production"
class EnvironmentSettings(BaseSettings):
"""Environment configuration with validation."""
environment: EnvironmentName = EnvironmentName.LOCAL
aws_region: str = "us-east-1"
aws_endpoint_url: Optional[str] = None
aws_access_key_id: Optional[str] = None
aws_secret_access_key: Optional[str] = None
s3_bucket: str = "ai-assets-local"
lambda_function_name: str = "ai-processor-local"
@field_validator("environment", mode="before")
@classmethod
def validate_environment(cls, v):
if isinstance(v, str):
v = v.lower().strip()
return v
@property
def is_local(self) -> bool:
return self.environment == EnvironmentName.LOCAL
@property
def is_aws(self) -> bool:
return self.environment in (EnvironmentName.STAGING, EnvironmentName.PRODUCTION)
@property
def is_production(self) -> bool:
return self.environment == EnvironmentName.PRODUCTION
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
use_enum_values = True
Step 2: Function to create generic clients
"""clients/base.py — Creating environment-agnostic boto3 clients."""
import boto3
from config.environment import EnvironmentSettings
def create_client(service: str, settings: EnvironmentSettings):
"""Creates a boto3 client configured for the current environment.
In local (LocalStack): uses endpoint_url and test credentials.
In AWS (staging/prod): uses the IAM role (no endpoint_url).
"""
kwargs = {"region_name": settings.aws_region}
if settings.aws_endpoint_url:
kwargs["endpoint_url"] = settings.aws_endpoint_url
if settings.aws_access_key_id:
kwargs["aws_access_key_id"] = settings.aws_access_key_id
kwargs["aws_secret_access_key"] = settings.aws_secret_access_key
return boto3.client(service, **kwargs)
def create_s3_client(settings: EnvironmentSettings):
return create_client("s3", settings)
def create_lambda_client(settings: EnvironmentSettings):
return create_client("lambda", settings)
Step 3: Verify it works in both environments
"""verify_abstraction.py — Verifies that the same code works in both environments."""
import os
from config.environment import EnvironmentSettings
from clients.base import create_s3_client
def verify_environment():
"""Verifies connectivity to the configured environment."""
settings = EnvironmentSettings()
print(f"Environment: {settings.environment}")
print(f"Bucket: {settings.s3_bucket}")
print(f"Endpoint: {settings.aws_endpoint_url or 'AWS default'}")
print(f"Region: {settings.aws_region}")
print()
s3 = create_s3_client(settings)
try:
s3.head_bucket(Bucket=settings.s3_bucket)
print(f"✅ Bucket '{settings.s3_bucket}' accessible")
except s3.exceptions.ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code == "404":
print(f"⚠️ Bucket '{settings.s3_bucket}' doesn't exist. Creating it...")
s3.create_bucket(Bucket=settings.s3_bucket)
print(f"✅ Bucket created")
else:
print(f"❌ Error: {e}")
return False
except Exception as e:
print(f"❌ Cannot connect to the environment: {e}")
return False
test_key = "_health/connection-test.txt"
s3.put_object(
Bucket=settings.s3_bucket,
Key=test_key,
Body=b"connection test OK",
)
response = s3.get_object(Bucket=settings.s3_bucket, Key=test_key)
content = response["Body"].read().decode("utf-8")
assert content == "connection test OK"
s3.delete_object(Bucket=settings.s3_bucket, Key=test_key)
print(f"✅ Read/write verified on '{settings.s3_bucket}'")
return True
if __name__ == "__main__":
verify_environment()
Execution:
# Against LocalStack
ENVIRONMENT=local python verify_abstraction.py
# Output:
# Environment: local
# Bucket: ai-assets-local
# Endpoint: http://localhost:4566
# ✅ Bucket 'ai-assets-local' accessible
# ✅ Read/write verified
# Against AWS (if you have an account)
ENVIRONMENT=staging python verify_abstraction.py
# Output:
# Environment: staging
# Bucket: ai-assets-staging-123456
# Endpoint: AWS default
# ✅ Bucket 'ai-assets-staging-123456' accessible
# ✅ Read/write verified
Same script, same code, different environments. The ENVIRONMENT variable and the corresponding .env file make all the difference.
The Transparent Pattern: boto3 Doesn't Need to Know
What boto3 really needs
boto3 operates on a simple principle: if you give it endpoint_url, it talks to that endpoint. If not, it talks to real AWS. Everything else (credentials, region) works the same:
import boto3
# These two clients are functionally equivalent
# for S3 operations:
# LocalStack client
local = boto3.client("s3", endpoint_url="http://localhost:4566",
aws_access_key_id="test",
aws_secret_access_key="test")
# AWS client
aws = boto3.client("s3")
# The same operation works with both:
# local.put_object(Bucket="b", Key="k", Body=b"data")
# aws.put_object(Bucket="b", Key="k", Body=b"data")
Endpoint switching is the mechanism that makes the whole abstraction possible. You don't need complex wrappers or enterprise abstractions — just control which parameters boto3.client() receives.
Complete pattern: environment-agnostic business logic
"""services/document_processor.py — 100% environment-agnostic business logic."""
import json
from datetime import datetime
class DocumentProcessor:
"""Processes AI documents. It doesn't know or care about the environment."""
def __init__(self, s3_client, bucket_name: str):
self.s3 = s3_client
self.bucket = bucket_name
def get_prompt_template(self, name: str, version: str) -> str:
key = f"prompts/{name}/{version}/system.txt"
response = self.s3.get_object(Bucket=self.bucket, Key=key)
return response["Body"].read().decode("utf-8")
def store_document(self, collection: str, doc_id: str, data: dict) -> str:
key = f"documents/{collection}/{doc_id}.json"
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=json.dumps(data, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
return key
def save_response(self, request_id: str, result: dict) -> str:
now = datetime.utcnow()
key = f"responses/{now.strftime('%Y/%m/%d')}/{request_id}.json"
payload = {
"request_id": request_id,
"timestamp": now.isoformat(),
"result": result,
}
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
return key
def process(self, prompt_name: str, prompt_version: str, document: dict) -> dict:
template = self.get_prompt_template(prompt_name, prompt_version)
stored_key = self.store_document(
"inbox", document.get("id", "unknown"), document
)
result = {
"prompt_used": f"{prompt_name}/{prompt_version}",
"document_key": stored_key,
"processed": True,
"summary": f"Processed with template: {template[:50]}...",
}
response_key = self.save_response(
f"req-{datetime.utcnow().strftime('%H%M%S')}", result
)
result["response_key"] = response_key
return result
Usage in any environment:
from config.environment import EnvironmentSettings
from clients.base import create_s3_client
from services.document_processor import DocumentProcessor
settings = EnvironmentSettings()
s3 = create_s3_client(settings)
processor = DocumentProcessor(s3, settings.s3_bucket)
result = processor.process(
prompt_name="summarizer",
prompt_version="v1",
document={"id": "doc-001", "content": "Document text..."},
)
print(result)
DocumentProcessor doesn't import os, doesn't read environment variables, doesn't have if local/aws. It receives an S3 client and a bucket name — and it works.
Troubleshooting
Problem 1: "Could not connect to the endpoint URL" with LocalStack
LocalStack isn't running or the port is different.
# Verify that LocalStack is running
docker ps | grep localstack
# If it doesn't appear:
docker run -d --name localstack -p 4566:4566 localstack/localstack
# Verify connectivity
curl http://localhost:4566/_localstack/health
Problem 2: The client connects to AWS when it should go to LocalStack
The ENVIRONMENT variable isn't set or the .env isn't loaded.
import os
print(f"ENVIRONMENT = {os.environ.get('ENVIRONMENT', 'NOT DEFINED')}")
# If it's empty, Pydantic Settings uses the default ("local")
# But if you have AWS credentials in ~/.aws, boto3 might use AWS anyway
# Solution: make sure .env.local has AWS_ENDPOINT_URL
Problem 3: "Invalid endpoint" when switching from local to staging
The staging .env has AWS_ENDPOINT_URL set (it shouldn't).
# .env.staging — CORRECT
ENVIRONMENT=staging
AWS_REGION=us-east-1
S3_BUCKET=ai-assets-staging-123456
# No AWS_ENDPOINT_URL → boto3 uses AWS default
# .env.staging — INCORRECT
ENVIRONMENT=staging
AWS_ENDPOINT_URL=http://localhost:4566 # ← This connects to LocalStack, not AWS
Problem 4: Pydantic doesn't load the correct .env file
Pydantic Settings loads .env by default. To load .env.local or .env.staging, you need to specify it:
from pydantic_settings import BaseSettings
import os
env_name = os.environ.get("ENVIRONMENT", "local")
class Settings(BaseSettings):
class Config:
env_file = f".env.{env_name}"
Or use the ENVIRONMENT variable directly as a system env var, and the .env only for additional values.
Practical Exercises
Exercise 1: Multi-service client factory
Create a create_clients function that takes an EnvironmentSettings and returns a dictionary with clients for S3, Lambda, and CloudWatch Logs — all configured for the same environment.
See solution
import boto3
from config.environment import EnvironmentSettings
def create_clients(settings: EnvironmentSettings) -> dict:
"""Creates clients for multiple AWS services in the current environment."""
services = ["s3", "lambda", "logs"]
clients = {}
kwargs = {"region_name": settings.aws_region}
if settings.aws_endpoint_url:
kwargs["endpoint_url"] = settings.aws_endpoint_url
if settings.aws_access_key_id:
kwargs["aws_access_key_id"] = settings.aws_access_key_id
kwargs["aws_secret_access_key"] = settings.aws_secret_access_key
for service in services:
clients[service] = boto3.client(service, **kwargs)
return clients
settings = EnvironmentSettings()
clients = create_clients(settings)
print(f"Environment: {settings.environment}")
print(f"Clients created: {list(clients.keys())}")
buckets = clients["s3"].list_buckets()
print(f"Accessible buckets: {len(buckets.get('Buckets', []))}")
Exercise 2: Environment health check
Implement a check_environment_health function that verifies connectivity to S3 and Lambda for the current environment, returning a dict with the status of each service.
See solution
import boto3
from config.environment import EnvironmentSettings
def check_environment_health(settings: EnvironmentSettings) -> dict:
"""Verifies the health of the services in the current environment."""
kwargs = {"region_name": settings.aws_region}
if settings.aws_endpoint_url:
kwargs["endpoint_url"] = settings.aws_endpoint_url
if settings.aws_access_key_id:
kwargs["aws_access_key_id"] = settings.aws_access_key_id
kwargs["aws_secret_access_key"] = settings.aws_secret_access_key
health = {
"environment": settings.environment,
"services": {},
"overall": "healthy",
}
# Check S3
try:
s3 = boto3.client("s3", **kwargs)
s3.list_buckets()
health["services"]["s3"] = {"status": "healthy", "error": None}
except Exception as e:
health["services"]["s3"] = {"status": "unhealthy", "error": str(e)}
health["overall"] = "degraded"
# Check Lambda
try:
lam = boto3.client("lambda", **kwargs)
lam.list_functions(MaxItems=1)
health["services"]["lambda"] = {"status": "healthy", "error": None}
except Exception as e:
health["services"]["lambda"] = {"status": "unhealthy", "error": str(e)}
health["overall"] = "degraded"
# Check bucket access
try:
s3 = boto3.client("s3", **kwargs)
s3.head_bucket(Bucket=settings.s3_bucket)
health["services"]["s3_bucket"] = {"status": "healthy", "error": None}
except Exception as e:
health["services"]["s3_bucket"] = {"status": "unhealthy", "error": str(e)}
health["overall"] = "degraded"
return health
settings = EnvironmentSettings()
result = check_environment_health(settings)
print(f"Environment: {result['environment']}")
print(f"Overall status: {result['overall']}")
for service, info in result["services"].items():
status_icon = "✅" if info["status"] == "healthy" else "❌"
print(f" {status_icon} {service}: {info['status']}")
Exercise 3: Environment comparison report
Create a script that connects to two environments (local and staging) and compares which buckets and Lambda functions exist in each, generating a report of the differences.
See solution
import boto3
from config.environment import EnvironmentSettings, EnvironmentName
def get_environment_resources(settings: EnvironmentSettings) -> dict:
"""Lists the resources available in an environment."""
kwargs = {"region_name": settings.aws_region}
if settings.aws_endpoint_url:
kwargs["endpoint_url"] = settings.aws_endpoint_url
if settings.aws_access_key_id:
kwargs["aws_access_key_id"] = settings.aws_access_key_id
kwargs["aws_secret_access_key"] = settings.aws_secret_access_key
resources = {"buckets": [], "functions": []}
try:
s3 = boto3.client("s3", **kwargs)
response = s3.list_buckets()
resources["buckets"] = [b["Name"] for b in response.get("Buckets", [])]
except Exception as e:
resources["buckets_error"] = str(e)
try:
lam = boto3.client("lambda", **kwargs)
response = lam.list_functions()
resources["functions"] = [
f["FunctionName"] for f in response.get("Functions", [])
]
except Exception as e:
resources["functions_error"] = str(e)
return resources
def compare_environments():
"""Compares resources between the local and staging environments."""
local_settings = EnvironmentSettings(
environment=EnvironmentName.LOCAL,
aws_endpoint_url="http://localhost:4566",
aws_access_key_id="test",
aws_secret_access_key="test",
s3_bucket="ai-assets-local",
)
local_res = get_environment_resources(local_settings)
print("=" * 60)
print("ENVIRONMENT COMPARISON REPORT")
print("=" * 60)
print(f"\n📍 LOCAL (LocalStack)")
print(f" Buckets: {local_res['buckets'] or 'none'}")
print(f" Functions: {local_res['functions'] or 'none'}")
# For staging, you'd need real AWS credentials
# Here we show the pattern:
print(f"\n📍 STAGING (AWS)")
print(f" (Requires configured AWS credentials)")
print(f"\n📊 DIFFERENCES:")
print(f" Resources only in local: {len(local_res['buckets'])} buckets")
print(f" The full report requires access to both environments")
compare_environments()
Exercise 4: Migrating assets between environments
Implement a function that copies all the prompt templates from one environment (LocalStack) to another (AWS staging), verifying that each template was copied correctly.
See solution
import boto3
from config.environment import EnvironmentSettings, EnvironmentName
def migrate_prompts(
source_settings: EnvironmentSettings,
target_settings: EnvironmentSettings,
dry_run: bool = True,
) -> dict:
"""Migrates prompt templates from one environment to another."""
def make_client(settings):
kwargs = {"region_name": settings.aws_region}
if settings.aws_endpoint_url:
kwargs["endpoint_url"] = settings.aws_endpoint_url
if settings.aws_access_key_id:
kwargs["aws_access_key_id"] = settings.aws_access_key_id
kwargs["aws_secret_access_key"] = settings.aws_secret_access_key
return boto3.client("s3", **kwargs)
source_s3 = make_client(source_settings)
target_s3 = make_client(target_settings)
paginator = source_s3.get_paginator("list_objects_v2")
results = {"migrated": [], "failed": [], "skipped": []}
for page in paginator.paginate(
Bucket=source_settings.s3_bucket, Prefix="prompts/"
):
for obj in page.get("Contents", []):
key = obj["Key"]
if dry_run:
results["skipped"].append(key)
print(f" [DRY RUN] Would migrate: {key}")
continue
try:
response = source_s3.get_object(
Bucket=source_settings.s3_bucket, Key=key
)
body = response["Body"].read()
target_s3.put_object(
Bucket=target_settings.s3_bucket,
Key=key,
Body=body,
ContentType=response.get("ContentType", "application/octet-stream"),
)
# Verify
check = target_s3.get_object(
Bucket=target_settings.s3_bucket, Key=key
)
check_body = check["Body"].read()
if body == check_body:
results["migrated"].append(key)
print(f" ✅ Migrated and verified: {key}")
else:
results["failed"].append(key)
print(f" ❌ Content doesn't match: {key}")
except Exception as e:
results["failed"].append(key)
print(f" ❌ Error migrating {key}: {e}")
print(f"\nResult: {len(results['migrated'])} migrated, "
f"{len(results['failed'])} failed, "
f"{len(results['skipped'])} skipped (dry_run)")
return results
source = EnvironmentSettings(
environment=EnvironmentName.LOCAL,
aws_endpoint_url="http://localhost:4566",
aws_access_key_id="test",
aws_secret_access_key="test",
s3_bucket="ai-assets-local",
)
# For a real migration, target would be AWS staging
# target = EnvironmentSettings(environment="staging", s3_bucket="ai-assets-staging-xxx")
# Dry run against the same environment (for demo)
migrate_prompts(source, source, dry_run=True)
Summary
- Environment abstraction is the most fundamental cloud migration pattern. Without it, every environment change requires editing source code — unsustainable in teams and dangerous in production.
- What changes between environments goes in configuration, what doesn't goes in code. Endpoints, credentials, bucket names → config. Business logic → code.
- Three maturity levels: Simple variable → config dict → Pydantic Settings with .env files. This module takes you to level 3.
- boto3 is naturally abstract. It only needs
endpoint_urlfor LocalStack; without it, it talks to AWS. Endpoint switching is all you need. DocumentProcessoris the example to follow. It receives a client and a bucket name. It doesn't know the environment. It doesn't care.- In the next capsule, you'll go deeper into config management — Pydantic Settings, validation, secrets, and per-environment .env files.
Additional Resources
- The Twelve-Factor App — Config — The principle: config in the environment, not in the code
- boto3 Configuration — How boto3 resolves configuration
- Pydantic Settings — Typed settings management
- LocalStack Configuration — Configuring LocalStack
- AWS SDK Configuration — AWS SDK configuration reference
- Environment Variables Best Practices — Principles of external configuration