I recently developed a full-stack application for a non-profit organization using React for the frontend, Python (FastAPI) for the backend, and PostgreSQL for data persistence. A seemingly straightforward requirement was enabling the backend to communicate with various public APIs while maintaining secure database access. The application architecture consisted of three containerized services: a React frontend, a Python backend API, and a PostgreSQL database, orchestrated using Docker Compose for local development. After successful local testing, the next challenge was architecting a production-grade deployment on AWS while optimizing for both maintenance overhead and cost efficiency.
While there are a number of ways to host the application, AWS was the preferred Cloud provider, because the non profit organization was planning to use some AWS grants and credits to host and run the application. When I started looking for the appropriate AWS services to run the application, my goal was to balance between 2 critical factors
After some google searches and taking opinions from GPTs, I landed on AWS AppRunner as my server of choice for compute, with RDS being the database solution. However, I learnt the hard way that it proved not to be the right fit for my needs.
AWS App Runner is a fully managed container service designed for running web applications and APIs at scale. While it might appear similar to AWS Lambda, App Runner serves a different use case - instead of Lambda's event-driven, single-threaded function execution model, App Runner provides continuous running of containerized applications with built-in auto-scaling capabilities. AppRunner is built on top of ECS/Fargate with more responsibilities managed by AWS. This also means when we run AppRunner Service we will not see the underlying the services in our account and they are managed by AWS. The below image shows the responsibility of aws vs customer while using App Runner.
Some of the key benefits of App Runner that I liked were
As you could see, these were the exact same benefits I was looking for. Minimal services to deal with on your account, so the Non profit does not need a technical team to maintain the services or deal with scaling or security issues, doesn’t need another CI/CD solution and managing container images in ECR paying additional for them, and the overall cost was not bad with an estimate of $50 - $60 per month.
Another key feature that impressed me was AppRunner allows you to configure environment variables from Secret store directly. By providing the arn for the secret, we access the complete json in the env variable, or directly specify the key and access its value. This means, I can get the RDS username password managed by Secrets Manager directly into environment variables securely, without any additional code in my application.
The migration from a local Docker Compose setup to AWS App Runner revealed several architectural limitations and deployment constraints that weren't immediately apparent from the service documentation.
As I mentioned earlier I had a 3 container setup for local development, and docker compose that was building containers with appropriate context. AppRunner does not support multiple containers in a single service, and I cannot use docker compose and run both frontend and backend container in AppRunner without any changes. I have to run them as 2 separate containers or use some other service like Amplify for front end which I was okay with, and wanted to deploy only the backend container in AppRunner.
However, AppRunner was not very happy with this setup, as it expects the application directory structure to be a standard python application at root. It could not find my requirements.txt on the root and the base path value in the AppRunner configuration did not help.
Additionally, I had to run the follow RUN command on my backend container as I used sql alchemy to manage my database migration scripts.
alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000
This start up command could not work on the container image built by AppRunner. With continuous failures and hours of troubleshooting, I had to split my application into different github repositories, move the RUN command to a separate startup script and connect the new repository to AppRunner to build the image successfully.
Alternatively, I can combine both the frontend and backend containers into a single image, build it outside of AWS and upload to ECR and use that image on AppRunner. This seemed more easier than battling with the AppRunner’s built in build step, however, AppRunner does not allow to expose more than 1 port. So if you want both front end and backend (say swagger) both running on the same container and exposed on different ports, AppRunner doesn’t support it.
AppRunner by default creates the services in public subnet which allows both inbound and outbound internet calls. However if you want to talk to another aws service such as RDS, the AppRunner should be connected to the RDS VPC, which will deploy AppRunner on private subnets. This means, AppRunner can either talk to RDS or internet, but not both by default. This demands an expensive NAT gateway to be setup, which for the instance alone could cost upto a $100 (more than AppRunner charges) for 3 AZ deployment. Now, the documentation says Private link can help talk to RDS while on public subnet, but that was just misleading and does not work. With more research around this I learned, some people found backdoor ways to fix this by either deploying their own NAT instances on a EC2 server and manage it separately, or acquire public IPs and attach them to the AppRunner VPC Endpoint to RDS to allow internet traffic. This is completely unsupported, and any scaling event in AppRunner can simply disconnect and the application could lose its connection to internet. And all these were workarounds that beat both my basic goals, which were minimum services to manage and low cost.
After more than 2 weeks of struggle, I ended up with wrapping my AppRunner journey and deploying the application in ECS as Fargate tasks with RDS and ECS running on the same VPC which had both public and private subnets. Now I have a single image that runs both front end and backend on the same container, exposes both the frontend and backend (swagger) port, connect to Internet without a NAT gateway, and talk to RDS which is on the private subnets and not publicly accessible. While this set up has a need to deal with load balancer, VPC setup, subets, security groups etc, this is much easier and a lot transparent to work with, because we have better control. And this is much cheaper than the other stack, since both frontend and backend containers together cost less than $50 a month satisfying all the necessary requirements.
While AWS App Runner offers compelling benefits with its managed container approach and operational simplicity, its current limitations make it better suited for simple, standalone applications. For services that even need a little complex networking or customized startup scripts, then AppRunner has a lot of limitations, and there is a lack of visibility and control which makes it a poor choice of service. For production deployments requiring intricate service communication patterns, traditional container orchestration services like ECS with Fargate provide better control and flexibility, albeit with additional configuration overhead.