Security and GDPR Compliance on AWS EKS: What We Built and Why
mypie processes personal financial data — pie (investment portfolio) positions, subscription status, user identities. This post covers every security control we've implemented across AWS, Kubernetes, and network layers, and maps each to the relevant GDPR article.
Why This Matters
mypie is a fintech product targeted at EU users. Users link their investment accounts, we track portfolio positions, and we generate PDF reports. That means we hold:
- User identities (email, name)
- Financial data (portfolio positions, transactions)
- Generated documents (PDF reports in S3)
- Behavioral data (subscription events, feature usage)
Under GDPR, this puts us squarely under Articles 5, 25, 30, 32, and potentially 35. Getting this right from day one — not as a retrofit — was a design constraint, not an afterthought.
Here’s every control we’ve implemented, and why.
Network Layer: Private by Default
VPC Architecture
All EKS worker nodes live in private subnets. They have no public IP, no direct internet route. Outbound traffic goes through a NAT gateway; inbound traffic only arrives through the Load Balancer.
Internet
↓
AWS ALB (public subnet, managed by LBC)
↓
EKS worker nodes (private subnets: 10.x.x.0/24)
↓
RDS/Redis/DynamoDB (private subnets, VPC-only endpoints)
This means even if a pod is compromised, the attacker can’t directly SSH in from the internet. They’d need to pivot through the ingress path, which is TLS-only and terminates at the load balancer.
Security Groups
Each tier has its own security group:
- EKS nodes — allow inbound only from the ALB security group on app ports
- Redis (ElastiCache) — allow inbound only from EKS node security group on port 6379
- VPC Flow Logs — enabled on the staging and prod VPCs, shipped to CloudWatch
VPC Flow Logs are our network audit trail. If an unusual connection pattern appears (unexpected outbound to an external IP, lateral movement within the VPC), we can retroactively trace it.
IAM: Least Privilege via IRSA
We do not use long-lived AWS access keys anywhere in the cluster. Every pod that needs AWS access uses IRSA (IAM Roles for Service Accounts) — temporary credentials minted by the EKS OIDC provider, scoped to a specific Kubernetes service account.
How IRSA Works
Pod starts with serviceAccount: api-gateway
↓
EKS OIDC token injected at /var/run/secrets/...
↓
Pod calls sts:AssumeRoleWithWebIdentity
↓
AWS returns 15-minute credentials scoped to api-gateway-role
↓
api-gateway-role has: s3:GetObject on pdfs bucket, dynamodb:Query on users table
↓
Credentials expire, no rotation needed
Each service has its own IAM role with only the permissions it needs. The api-gateway IRSA role cannot write to the DynamoDB subscriptions table. The notification-service role cannot read from the PDFs bucket. Blast radius of a compromised pod is limited to that service’s permissions.
We have 8 IRSA roles for application services, plus:
aws-load-balancer-controller— EC2/ELB permissions for ALB provisioningatlantis— AdministratorAccess (scoped to staging cluster OIDC, used only for Terraform applies under PR review)
No Secrets in Environment Variables
Sensitive config is stored in AWS AppConfig (feature flags, runtime config) and AWS Secrets Manager (database credentials, API keys). Pods read these at startup via the IRSA-backed AWS SDK. No secrets in Kubernetes ConfigMap, no secrets in Dockerfile ENV, no secrets in Git.
Encryption: At Rest and In Transit
At Rest
| Resource | Encryption |
|---|---|
| DynamoDB tables | AWS-managed KMS key (default, regional) |
| S3 buckets | SSE-S3 (AES-256), enforced by bucket policy |
| Redis (ElastiCache) | at-rest encryption enabled |
| EBS volumes (EKS nodes) | encrypted AMI + EBS encryption by default |
| CloudTrail logs | SSE-KMS |
In Transit
All external traffic is TLS 1.2+ — enforced at the ALB level. The ALB uses an ACM wildcard cert (*.mypie.io) with automated renewal.
For the PDFs S3 bucket specifically, we enforce TLS via bucket policy — any request over HTTP is explicitly denied:
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::mypie-pdfs-prod", "arn:aws:s3:::mypie-pdfs-prod/*"],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
}
This means even if someone misconfigures an http:// URL to the bucket, the bucket rejects the request. GDPR Article 32 requires “appropriate technical measures” — this is exactly that.
Audit Trail: CloudTrail
GDPR Article 30 requires records of processing activities. Article 32 requires technical measures to ensure security, including the ability to detect, respond to, and investigate breaches.
We enable CloudTrail with data events on the resources that touch personal data:
# terraform/modules/cloudtrail/main.tf
resource "aws_cloudtrail" "audit" {
name = "mypie-${var.environment}-audit"
s3_bucket_name = aws_s3_bucket.trail.id
event_selector {
read_write_type = "All"
include_management_events = true
data_resource {
type = "AWS::DynamoDB::Table"
values = var.dynamodb_table_arns # users + subscriptions tables
}
data_resource {
type = "AWS::S3::Object"
values = ["${var.s3_pdf_bucket_arn}/"] # every PDF read/write
}
}
}
Every GetItem, PutItem, DeleteItem on the users and subscriptions tables is logged. Every GetObject on the PDFs bucket is logged. This gives us:
- A complete record of who (which IAM principal) accessed personal data, when, and what they did
- Inputs for a breach investigation (Article 33 requires notification within 72 hours)
- Documentation for an Article 30 Record of Processing Activities audit
CloudTrail logs flow to a dedicated S3 bucket with:
- Access logging enabled (who viewed the audit logs)
- Object lock for tamper-evidence
- Cross-region replication disabled (EU data stays in eu-central-1)
Threat Detection: GuardDuty
GuardDuty continuously analyses CloudTrail, VPC Flow Logs, and DNS logs for anomalous behaviour:
- Port scanning from within the VPC
- IAM principal making API calls from an unusual location
- Cryptocurrency mining (common post-compromise)
- Exfiltration patterns (large data transfer to external IP)
We enable GuardDuty in both staging and prod via a Terraform module:
module "guardduty" {
source = "../../modules/guardduty"
environment = local.environment
project = local.project
}
GuardDuty findings have severity levels (LOW/MEDIUM/HIGH). HIGH findings trigger a CloudWatch alarm. In a future iteration we’ll route these to PagerDuty for on-call alerting.
Data Protection: DynamoDB
Deletion Protection (Prod Only)
Production DynamoDB tables have deletion protection enabled:
resource "aws_dynamodb_table" "users" {
name = "mypie-users-prod"
deletion_protection_enabled = var.environment == "prod" ? true : false
}
A terraform destroy of the prod environment won’t drop the users table. A runaway aws dynamodb delete-table call returns an error. This is GDPR Article 32 — protecting integrity and availability of personal data.
Point-in-Time Recovery
PITR is enabled on all personal-data tables. We can restore the users or subscriptions table to any second within the last 35 days. This protects against both accidental deletion and ransomware-style corruption.
GDPR: Privacy by Design (Article 25)
Article 25 requires Data Protection by Design and by Default — privacy should be the default, not an opt-in.
Concretely this means:
Data minimisation: we only collect the fields we actually use. The users table stores email, hashed password, and subscription tier — not phone number, not date of birth, not IP address history.
Purpose limitation: each IRSA role can only access the tables/buckets relevant to its service. The ai-service can read portfolio position data, but not the users table. Cross-service data access is enforced at the IAM boundary, not just at the application level.
Confidentiality by default: all new S3 buckets have block_public_access = true. No bucket requires a conscious decision to be public — you’d have to explicitly un-block it. Same for DynamoDB: no table is publicly accessible.
GDPR: Cross-Account Access Prevention
For the PDFs bucket, we have an additional policy statement that prevents any principal outside our own AWS account from accessing objects:
{
"Sid": "DenyNonAccountAccess",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::mypie-pdfs-prod/*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalAccount": "589528730153"
}
}
}
This ensures personal data in PDFs cannot be shared with another AWS account via resource-based policy misconfiguration — a common vector in multi-tenant SaaS incidents.
Kubernetes Security
Pod Security
- No pods run as
root— all containers use a non-root UID - No
hostPid,hostNetwork, orprivilegedcontainers - Resource requests and limits on every deployment — prevents a single runaway pod from starving others (denial of service to the cluster)
Secrets Management
Kubernetes Secret objects are base64-encoded, not encrypted — anyone with kubectl get secret access can read them. We minimise what’s stored in Kubernetes secrets:
- Application credentials come from AppConfig/Secrets Manager via IRSA (no K8s secret needed)
- The only K8s secrets we have are: ArgoCD repository credentials (read-only GitHub token), Atlantis webhook/GH token
Network Policies (Planned)
Currently, EKS pods can talk to each other freely within the cluster. On the roadmap: Kubernetes NetworkPolicies to restrict inter-pod communication to what’s declared. Until then, VPC Security Groups provide the outer boundary.
Summary: GDPR Article Mapping
| GDPR Article | Control |
|---|---|
| Art. 5 — Integrity & confidentiality | Encryption at rest, TLS in transit, IRSA least-privilege |
| Art. 25 — Privacy by design | Private VPC, block public access, data minimisation, IRSA scoping |
| Art. 30 — Records of processing | CloudTrail data events on personal-data tables + PDF bucket |
| Art. 32 — Security of processing | GuardDuty, VPC flow logs, CloudTrail, TLS-only bucket policy, DynamoDB deletion protection |
| Art. 33 — Breach notification | GuardDuty HIGH severity alerts, CloudTrail for forensic timeline |
Security is not a checkbox. Every one of these controls was built in response to a concrete threat model: “what happens if an attacker gets a pod’s credentials?” “what happens if a developer accidentally deletes a DynamoDB table?” “what happens if we get a breach notification request and have 72 hours to respond?” The answers drove the architecture.
eu-central-1 (Frankfurt). No personal data is replicated outside the EU. S3 replication rules, DynamoDB global tables, and CloudTrail cross-region replication are all explicitly disabled. This supports our GDPR Article 44-49 (international transfers) stance that we simply don't do international transfers of personal data.