Many software projects work perfectly on localhost.
The challenge begins when they need to handle real users, large files, asynchronous workloads, security requirements, and long-term availability.
We worked on a project called Testimonios de Bolivia, a platform designed to preserve oral history through audio and video testimonies. The goal was simple:
Preserve historical memory while making the information searchable, accessible, and resilient.
The first version was fully functional on a local environment. It used an Angular 19 frontend built with standalone components and lazy-loaded routes, Bun + Express for the backend, PostgreSQL for persistence, Cloudinary for multimedia assets, and Deepgram for audio transcription. While functional, the architecture had serious structural limitations.
In this article, we'll explain how we migrated the platform to AWS and redesigned it as a fully event-driven cloud-native system.
The Problem with the Initial Architecture
Our initial architecture was simple: a single backend in Bun + Express handled absolutely everything. API requests, authentication, file uploads, Cloudinary storage, and audio transcription all lived in the same process. The browser connected directly to the backend for every operation, and PostgreSQL was the single database. All processing happened on the same server.
Everything worked.
Until we started asking difficult questions. What happened when users uploaded large videos? What about when dozens of uploads arrived at the same time? What happened if the server went down? And when audio transcription took several minutes, did the user have to wait?
The backend was doing too many things at the same time:
- Responding to API requests
- Processing file uploads before forwarding them to Cloudinary
- Handling authentication
- Running synchronous Deepgram transcription jobs
- Coordinating every operation as an intermediary
A single application instance was responsible for everything. Every file upload passed through the backend before reaching Cloudinary, consuming bandwidth and memory on the server. Every transcription request blocked the response until Deepgram returned the result. The backend was the bottleneck for operations it didn't even own.
Why We Chose an Event-Driven Architecture
The biggest pain point was multimedia processing.
Audio transcription is computationally expensive.
If the application waits for a transcription job to finish, every user request becomes slower.
Instead of processing files synchronously, we decided to redesign the workflow around events.
Traditional Approach
In the traditional flow, the browser sent the file to the backend, the backend forwarded it to Cloudinary, started the Deepgram transcription, and waited synchronously for it to finish before returning the response to the user. The entire process was synchronous: the user was blocked until the transcription finished, and server resources remained occupied for the entire operation. The user waits. The server waits. Resources remain occupied.
Traditional (Sync):
Event-Driven (Async):
Event-Driven Approach
With AWS, the flow changed completely. The Angular frontend requests a presigned URL from the backend and uploads the file directly to S3 through the HTTP client. When the file arrives, S3 emits an event to an SQS queue. A Lambda function consumes the message from the queue and starts an Amazon Transcribe job. Transcribe saves the result to S3, and a function updates the database. The backend only coordinates the flow — it never processes files or runs transcriptions. Now the upload and processing pipelines are completely decoupled. The API stays responsive regardless of the transcription load.
Direct Uploads with Presigned URLs
One of the architectural decisions we made was eliminating file uploads through the backend.
In the previous flow, the browser sent the file to the backend, which then forwarded it to Cloudinary. The backend acted as an intermediary, consuming bandwidth and server memory for every uploaded file.
We switched to a completely different flow. The Angular frontend requests a signed URL from the backend, the backend generates a presigned URL and returns it. Then the browser uploads the file directly to S3 using that URL. The backend never touches the file — it only generates
The benefits were clear:
- Reduced backend bandwidth consumption
- Lower server memory usage
- Faster uploads by eliminating the intermediary
- Better horizontal scalability
- Simpler infrastructure
This pattern is extremely useful whenever you're dealing with media-heavy applications
The architecture also enables capabilities that were impractical before. Video thumbnail generation, watermarking on download, and similar media transformations are now achievable through S3 event triggers and serverless processing — without modifying the backend. These features remain on the roadmap, but the foundation is already in place.
The Final Cloud Architecture
The final AWS architecture looked like this: CloudFront serves the Angular frontend — upgraded from 19 to 21 during the migration — hosted in an S3 bucket. Error pages are configured to return index.html, preserving the SPA routing that Angular's lazy-loaded routes depend on. The backend runs on ECS Fargate inside a VPC with private and public subnets. The backend connects to RDS PostgreSQL in a private subnet with no public access and to ElastiCache Redis for caching. File uploads go directly to an S3 bucket through presigned URLs generated by the backend. When a file arrives in S3, an event is emitted to an SQS queue. A Lambda function consumes messages from SQS and starts Amazon Transcribe jobs. The transcription results are saved to another S3 bucket, and the metadata is indexed in PostgreSQL. The entire processing flow is asynchronous and does not block the user.
Technologies Used
| Layer | Technology |
|---|---|
| Frontend | Angular 21 |
| Backend | Bun + Express |
| ORM | Prisma |
| Database | PostgreSQL |
| Containers | Docker |
| Container Runtime | AWS Fargate |
| CDN | CloudFront |
| Object Storage | Amazon S3 |
| Queue | Amazon SQS |
| Serverless Compute | AWS Lambda |
| Speech-to-Text | Amazon Transcribe |
| Cache | ElastiCache (Redis) |
| Secrets | AWS Secrets Manager |
Infrastructure Diagram
Challenge #1: Security Groups and Private Databases
One requirement was that PostgreSQL should never be publicly accessible. This sounds straightforward. In practice, it required carefully configuring Security Groups.
ECS Fargate connects to RDS through its Security Group, which only allows traffic from the ECS security group on port 5432. The database has no public IP address, only a private IP inside the VPC. This follows the principle of least privilege and significantly reduces the attack surface.
Challenge #2: Prisma Connection Strings
When using auto-generated credentials with high-entropy passwords, Prisma's parser can fail if the password contains reserved URL characters like $, >, or ). The fix was straightforward: percent-encode the reserved characters in the connection string stored in AWS Secrets Manager.
Challenge #3: ECS Deployment Circuit Breaker
During early deployments, misconfigured environment variables or IAM permissions caused new container tasks to fail initialization. Enabling the ECS Deployment Circuit Breaker meant that when health checks failed, ECS automatically stopped the deployment and reverted to the last stable task. This prevented several broken versions from reaching production.
Building an Asynchronous Transcription Pipeline
One of the most interesting parts of the project was the transcription workflow.
The flow starts when the user uploads an audio file. The file arrives in S3, which emits an ObjectCreated event. That event is queued in SQS and a Lambda function consumes it. The Lambda starts an Amazon Transcribe job, and when the transcription finishes, the results are saved to S3 and indexed in PostgreSQL. The backend never participates in the transcription process — it only generates the presigned URL and later reads the results from the database.
The result is a resilient processing pipeline capable of handling spikes without overwhelming the application server.
Monitoring and Observability
We centralized logs and metrics using Amazon CloudWatch, including ECS and Lambda logs, and application logs. This allowed us to monitor failed transcriptions, API errors, resource consumption, container health, and the SQS queue backlog.
Lessons Learned
If we had to summarize the biggest lessons from this migration:
1. Presigned URLs eliminated the backend as intermediary for uploads
Before, the backend received every file and forwarded it to Cloudinary, consuming bandwidth and memory for each upload. With presigned URLs, the backend only generates a token and the browser uploads directly to S3. Bandwidth, CPU, and memory consumption for uploads effectively disappeared.
2. SQS decoupled heavy processing from the API
Audio transcriptions no longer block API responses. The queue absorbs load spikes even when dozens of files arrive simultaneously. The backend never waits for a transcription to finish.
3. The ECS Deployment Circuit Breaker prevented production outages
Deployments failed due to misconfigured environment variables or incorrect IAM permissions. ECS's automatic rollback handled recovery without manual intervention.
4. Network configuration took longer than application code
Isolating PostgreSQL required private subnets, a NAT Gateway, Security Groups, and VPC endpoints. Each service needed specific inbound and outbound rules. One mistake in VPC routing could leave the backend without database connectivity.
5. Asynchronous transcription requires a feedback mechanism to the frontend
The S3 → SQS → Lambda → Transcribe pipeline works correctly, but the user needs to know when the transcription finishes. We implemented a status field in PostgreSQL and polling from the Angular frontend to check the testimony state.
Conclusion
Migrating from localhost to a cloud-native architecture was much more than a deployment exercise. It required rethinking how the application handled storage, processing, scalability, and resilience.
The result is a platform capable of preserving historical testimonies through a distributed architecture built on AWS managed services. What started as a monolithic application has become a decoupled, event-driven system that can grow with the number of testimonies it preserves.
GitHub Repo







Top comments (0)