Self-Hosting Strapi on AWS for Enterprise Apps
Self-hosting Strapi on AWS gives you a content layer that scales predictably and enforces strict data boundaries — but the operational complexity lands in three places: database connection management, media distribution, and webhook orchestration. This guide gives the root cause and exact AWS configuration for each, the failure modes that cascade into build failures and content latency when you get them wrong. It builds on Strapi Self-Hosted Setup, within Platform Integration Deep Dives.
The enterprise topology isolates compute from state and decouples webhooks from builds across three subsystems:
Infrastructure Topology & State Isolation
Root Cause Analysis
The most common production failure is connection exhaustion. Strapi builds queries through Knex.js, whose default pool is modest. Concurrent admin queries, webhook processing, and frontend API traffic saturate it fast, throwing ER_TOO_MANY_CONNECTIONS, halting publishes, and forcing ECS/EKS restarts.
Exact Implementation
Deploy Strapi compute on Amazon ECS Fargate or EKS, isolate state in Amazon RDS PostgreSQL, and enforce connection multiplexing via Amazon RDS Proxy.
- Provision RDS Proxy: Attach the proxy to your PostgreSQL instance. Configure the proxy target group with
MaxConnectionsPercent=100andConnectionBorrowTimeout=120. - Configure Knex Pooling via Environment Variables: Inject the following into your Strapi container environment or Kubernetes ConfigMap:
DATABASE_CLIENT=postgres
DATABASE_URL=postgresql://proxy-endpoint:5432/strapi_db
DATABASE_POOL_MIN=2
DATABASE_POOL_MAX=10
DATABASE_ACQUIRE_TIMEOUT_MILLIS=10000
DATABASE_IDLE_TIMEOUT_MILLIS=30000
- Update Strapi Database Config: Ensure
config/database.jsexplicitly reads the environment variables and passes them to the Knex pool configuration:
module.exports = ({ env }) => ({
connection: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
ssl: { ca: env('DATABASE_CA_CERT') }, // RDS CA bundle; never disable certificate verification
},
pool: {
min: parseInt(env('DATABASE_POOL_MIN', '2')),
max: parseInt(env('DATABASE_POOL_MAX', '10')),
acquireTimeoutMillis: parseInt(env('DATABASE_ACQUIRE_TIMEOUT_MILLIS', '10000')),
idleTimeoutMillis: parseInt(env('DATABASE_IDLE_TIMEOUT_MILLIS', '30000')),
},
debug: env.bool('DATABASE_DEBUG', false),
},
});
Prevention & Monitoring
- Connection Leak Detection: Enable
DATABASE_DEBUG=truein staging to log slow queries and unclosed connections. - Proxy Metrics: Monitor
DatabaseConnectionsCurrentlyBorrowedandDatabaseConnectionsCurrentlyInTransactionin CloudWatch. Set alarms at 80% ofDATABASE_POOL_MAX. - Graceful Shutdown: Configure ECS task stop timeout to
60seconds and implementprocess.on('SIGTERM')handlers in Strapi to drain active queries before termination.
Media Pipeline & S3 Integration Patterns
Root Cause Analysis
Local media on containerized Strapi bloats ephemeral storage and breaks horizontal scaling. Misconfigured S3 permissions and CORS block admin uploads, and wildcard CloudFront invalidations (/*) empty the edge cache and flood the origin.
Exact Implementation
Route all uploads through @strapi/provider-upload-aws-s3 with strict IAM scoping, CloudFront Origin Access Control (OAC), and programmatic cache purging.
- IAM Policy Scoping: Attach this policy to the ECS task role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::your-enterprise-cms-assets/*"
}
]
}
- Provider Configuration: In
config/plugins.js, map the provider to CloudFront:
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
baseUrl: `https://${env('CLOUDFRONT_DOMAIN')}`,
bucket: env('AWS_BUCKET_NAME'),
region: env('AWS_REGION'),
// No static keys: the SDK picks up the ECS task role's credentials automatically.
s3Options: { region: env('AWS_REGION') },
},
},
},
});
- S3 CORS Configuration: Apply this bucket CORS rule to allow Strapi admin uploads:
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://admin.yourdomain.com</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
</CORSRule>
</CORSConfiguration>
- Targeted Cache Invalidation: New uploads get new object keys, so they need no invalidation. Only replacing a file in place, which the media library allows, changes the content behind an existing URL. Subscribe to file updates in
src/index.jsand invalidate just that path:
const { CloudFrontClient, CreateInvalidationCommand } = require("@aws-sdk/client-cloudfront");
module.exports = {
register({ strapi }) {
strapi.db.lifecycles.subscribe({
models: ['plugin::upload.file'],
afterUpdate: async (event) => {
const cfClient = new CloudFrontClient({ region: process.env.AWS_REGION });
const path = new URL(event.result.url).pathname;
const command = new CreateInvalidationCommand({
DistributionId: process.env.CLOUDFRONT_DISTRIBUTION_ID,
InvalidationBatch: {
Paths: { Quantity: 1, Items: [path] },
CallerReference: `strapi-${Date.now()}`,
},
});
await cfClient.send(command);
},
});
},
};
Prevention & Cache Strategy
- Avoid Wildcards: Avoid
/*invalidations in production. They empty the whole distribution’s cache, send a wave of requests to the origin, and CloudFront limits how many wildcard invalidations can be in progress at once. Exact paths, or versioned file names that need no invalidation at all, are better. - OAC Enforcement: Replace legacy Origin Access Identity (OAI) with OAC to support modern S3 bucket policies and eliminate cross-account permission drift.
- Asset Versioning: Append query strings or hash-based filenames to media URLs in your frontend build step to bypass edge cache without manual invalidation.
Webhook Orchestration & Jamstack Build Triggers
Root Cause Analysis
Strapi sends one webhook per event, with no batching or queue buffering. A bulk publish sends simultaneous deliveries that overwhelm build APIs (Vercel, Netlify, CodeBuild), dropping triggers or hitting CI/CD rate limits.
Exact Implementation
Decouple Strapi webhooks from frontend build systems using Amazon SQS and AWS Lambda. Implement exponential backoff and signature verification.
- Strapi Webhook Configuration: Strapi does not sign webhook bodies, but it lets you add custom headers to each webhook. Add a header such as
x-webhook-secretwith a long random value, and point the webhook at an API Gateway endpoint. - Verify Before Queueing: Check the secret header before anything is queued, with a Lambda authorizer on the API Gateway route that compares it in constant time. Messages in SQS no longer carry the HTTP headers, so verification cannot happen in the consumer. Verified requests are mapped to
SendMessageon an SQS FIFO queue withMessageGroupIdset tostrapi-webhooks. - Lambda Build Trigger Function, consuming the queue in batches:
const { CodeBuild } = require('@aws-sdk/client-codebuild');
exports.handler = async (event) => {
// SQS delivers a batch of already verified events; one build covers all of them.
const models = new Set(event.Records.map((r) => JSON.parse(r.body).model));
console.log(JSON.stringify({ kind: 'strapi_publish_batch', events: event.Records.length, models: [...models] }));
const codebuild = new CodeBuild();
await codebuild.startBuild({
projectName: 'jamstack-frontend-prod',
sourceVersion: 'refs/heads/main',
});
return { batchItemFailures: [] };
};
- Retry & Dead-Letter Queue: Attach a DLQ to the SQS queue. Configure Lambda reserved concurrency to
5to prevent build system overload during traffic spikes.
Prevention & Idempotency
- Deduplication: Use
MessageDeduplicationIdin SQS FIFO queues to suppress duplicate webhook deliveries from network retries. - Build Coalescing: Implement a short delay (e.g., 30 seconds) in the Lambda function before triggering the build, or use a DynamoDB lock table to batch rapid successive publishes into a single CI/CD run.
- Content Team Guardrails: Subscribe the webhook to
entry.publish,entry.unpublishandentry.deleteonly. Plainentry.updateevents fire on every draft save and only add build noise.
Gotchas & Edge Cases
- Proxy and prepared statements. RDS Proxy pins a connection to a client session when it sees session state such as prepared statements or
SETcommands. Pinned connections defeat multiplexing; watch the proxy’s pinning metrics after enabling it. - Pool size across tasks. The Knex pool maximum applies per task. Ten tasks with a maximum of 20 open up to 200 connections, which the proxy must be allowed to hold.
- Admin and API on the same service. A bulk import in the admin competes with frontend traffic. Run a separate service for the admin panel, or at least give the API service its own autoscaling.
- Health checks during migrations. Strapi runs database migrations at startup. Give the first task after a deployment a generous health check grace period, or the load balancer kills it mid-migration.
- Secrets in task definitions. Load
APP_KEYS, JWT secrets and the webhook secret from Secrets Manager into the task definition, never as plain environment values in the repository.
Worked Example
An insurer moved Strapi from a single EC2 instance to ECS Fargate with three tasks behind an Application Load Balancer, RDS PostgreSQL behind RDS Proxy, uploads in S3 served through CloudFront, and webhooks through a secret-checked API Gateway into SQS. Before the move, the monthly bulk publish of policy documents regularly exhausted database connections and set off dozens of overlapping frontend builds. After it, the same bulk publish produced a single coalesced build, database connections stayed well below the proxy’s limit, and the instance-level outages that had required manual restarts stopped. The team also removed the static access keys that had been stored in the old server’s environment file.
Cost and Right-Sizing
Enterprise Strapi on AWS is usually cheaper than teams expect, because the expensive parts are shared managed services rather than the containers. Size Fargate tasks from measured memory under load, since Strapi is memory-bound rather than CPU-bound for most workloads, and scale on request count per target rather than CPU. RDS Proxy is billed per vCPU of the database, so it pays for itself only when connection churn is real; measure before adding it to small installations. CloudFront in front of S3 reduces egress costs as well as latency. The recurring surprise is logging volume: request logs from API Gateway, the load balancer and the tasks can exceed the compute bill, so set retention periods and sample high-volume logs from the start.
Rollout Checklist
- Run Strapi as stateless tasks on ECS or EKS, with secrets from Secrets Manager.
- Connect through RDS Proxy with TLS verified against the RDS CA bundle.
- Size the Knex pool per task and check the total against proxy limits.
- Store uploads in S3 using the task role, served through CloudFront with OAC.
- Invalidate only replaced files, never with wildcards.
- Add a secret header to webhooks, verify it before queueing, and coalesce builds.
- Attach a dead-letter queue and alarm on its depth.
Frequently Asked Questions
ECS or EKS?
ECS Fargate for teams without an existing Kubernetes platform: fewer moving parts and nothing to patch. EKS when the organization already runs Kubernetes and wants Strapi deployed with the same tooling as everything else.
Is RDS Proxy required?
No. Small installations with a few tasks and a correctly sized pool work fine without it. It helps when tasks scale up and down often, or when Lambda functions also connect to the database.
Does Strapi support Aurora?
Yes, Aurora PostgreSQL is compatible. Aurora Serverless can scale down for low-traffic environments, but cold resumes add latency to the first request after idle periods.
Can the webhook secret rotate without downtime?
Yes. Let the authorizer accept both old and new secrets, update the header in Strapi’s webhook settings, then remove the old secret once deliveries with the new one succeed.