Case Study



1 Overview


Bastion is a scalable, open-source Backend-as-a-Service that gets deployed to the cloud with AWS, allowing a frontend developer to quickly set up a backend while maintaining control of the code and infrastructure. Bastion is flexible and extensible, integrating with AWS Lambda to provide custom functionality for a variety of use cases. And because Bastion runs using AWS infrastructure, it scales automatically when more resources are required.


2 Backend-as-a-Service

2.1 Frontend vs Backend

It is convenient to think of a web application in two parts — the frontend and the backend. The frontend is the client-side code responsible for the user interface, while the backend is the server-side code that handles data management, storage, and manipulation. These two components communicate with each other to provide a fluid and interactive user experience.

Figure 1. Frontend and backend responsibilities.

Both the frontend and backend are essential for most applications. However, for many small companies developing new applications, the basic responsibilities of the backend are the same—to provide an API for the frontend to interact with the database, to manage user authentication, and to provide a file storage system. Since many small companies require the same backend features, it is potentially inefficient for their engineers to create a backend from scratch. Thankfully, there are a variety of services that allow frontend-focused companies to avoid the hassle of coding, configuring, deploying and managing their backend.


2.2 Fortress.io

For example, suppose Fortress.io is a small company with a handful of engineers developing a new app. Their frontend requires standard backend functionality, such as create, read, update, and delete operations (CRUD) for database collections, and user authentication.


Figure 2. Fortress.io.

There are several options available for Fortress.io. The team could build a backend from scratch and host it on their own machines, giving them full control over every part of their backend. However, creating and maintaining everything would require significant time and effort from the engineering team and they want to get their product to market as quickly as possible. Instead, Fortress.io could take advantage of a cloud hosting service. There are several types of services that abstract away the backend to varying degrees.


Figure 3. Comparison of different types of cloud hosting services.

An Infrastructure-as-a-Service (IaaS) provider would afford the highest degree of control but the lowest degree of abstraction. This type of service provider hosts server infrastructure in the cloud, freeing Fortress.io engineers from having to purchase, configure, and maintain backend hardware, while still requiring configuration and maintenance of the operating system and application code. Alternatively, a Platform-as-a-Service (PaaS) provider would handle all of the hardware considerations in addition to the operating-system-level concerns, letting the engineers at Fortress.io focus on the backend application code. But Fortress.io only requires common backend functionality, so coding a backend application could still be considered inefficient. Instead, Fortress.io could use a Backend-as-a-Service (BaaS) provider that generates a pre-built backend that fulfills their functionality requirements. This option provides the highest degree of abstraction, but at the cost of control — probably a worthwhile tradeoff for a small company trying to rapidly get their product to market.


2.3 Cloud Code

Using a BaaS provider can accelerate your time to market, but once your application outgrows the provided functionality, you will have to adapt. This means re-developing your backend and migrating it to another BaaS provider or to a custom-built backend. To improve extensibility and prolong the usefulness of their service, many BaaS providers have incorporated a Cloud Code feature that allows users to define custom functionality for their backend. This allows the BaaS consumer to continue using the BaaS provider even when the consumer’s needs have outgrown the basic functionality of the BaaS.

Figure 4. Adding a custom Cloud Code function that integrates with the Stripe API to the backend.

In addition to extending functionality beyond what is provided by the BaaS provider, another use case for Cloud Code is to securely integrate a third-party API in your project. For example, Fortress.io wants to use Stripe for payments from their customers. If they hard code the Stripe secret API key in their frontend code, then anyone who inspects their frontend code will be able to access their API key. If Fortress.io decides to use a BaaS provider without Cloud Code then they won’t be able to securely integrate Stripe into their application. But if their BaaS provider does have Cloud Code, then the Fortress.io engineers can define a custom function in the backend with access to the Stripe secret API key and then they can call that function from the frontend using an HTTP request. The advantages of Cloud Code have made it an increasingly important BaaS feature.1

Figure 5. Using an HTTP request from the frontend to invoke a custom Cloud Code function from the backend.

2.4 Managed BaaS vs Self-Hosted BaaS

There are many BaaS providers to choose from, and they fall into two broad groups: managed and self-hosted. Popular services such as Google Firebase and AWS Amplify are managed BaaS providers. The code and infrastructure is hosted, monitored, and maintained by the provider and is not accessible to the BaaS consumer. These conveniences allow consumers to rapidly deploy a backend with minimal time and effort, but at the expense of loss of control over their infrastructure and code.


In contrast, Parse and Appwrite are two examples of open-source self-hosted BaaS products. Consumers of these products can download the entire codebase and deploy the backend wherever they want—on their local machine, in the cloud, or on any other compatible hardware. The consumer has full access and control of the code, the environment, and their data, but they are responsible for hosting, monitoring, and maintaining the backend.


2.5 Choosing a BaaS Provider

Once you’ve decided to use a BaaS provider for your application, deciding between a managed service and self-hosted option can be difficult. While managed services are convenient and reduce your time to market, they lock your application to their services—you must design and implement your application around the interfaces provided by the managed BaaS service. This makes it difficult if you ever need to migrate your application away from the managed BaaS service. For example, if the BaaS provider ceases to exist, then you will have to create a backend from scratch or figure out how to migrate your application to a new BaaS provider. This has happened several times in recent years, including when Facebook dropped support for Parse in 20172 and when Microsoft retired their Mobile-Backend-as-a-Service in 2020.3

Figure 6. Comparison of managed and self-hosted BaaS services.

Another drawback of managed BaaS providers is that you do not have access to your backend code and infrastructure. This isn’t a concern for simple applications, but if you need to modify or customize your backend this could present a problem.


These potential problems can be avoided by using one of the open-source self-hosted BaaS options. These services give you full control of the code and infrastructure, allowing you to modify your backend as needed. The tradeoff is that they slow down your speed to market relative to a managed BaaS because they require installation, configuration, monitoring, and maintenance. Furthermore, if your application becomes successful and you need to scale your backend, you will be completely responsible for the scaling process, whereas managed BaaS providers typically scale automatically.


2.6 Deployment Strategies for a Self-Hosted BaaS

For a small company looking to reduce their time to market, keep control of their backend, and avoid vendor lock-in, deploying an open-source self-hosted BaaS would probably be the best option. While it is possible to purchase the necessary hardware and deploy the backend on premises, the high cost and maintenance make this an untenable strategy for a small team of engineers. It is much more efficient and cost effective to deploy the open-source BaaS in the cloud using a cloud provider like AWS or Azure.


While deploying to the cloud is faster than purchasing, configuring, deploying, and maintaining your own infrastructure, it can still be a relatively slow process. Researching what services you will need, designing your cloud architecture, connecting all the services to each other, and securing your backend are all time-consuming steps that require significant time and effort.


2.7 Bastion

This is where Bastion comes in. Bastion is an open-source self-hosted BaaS deployed to the cloud in your AWS account. Bastion is flexible and extensible, allowing you to easily create custom Cloud Code functions using AWS Lambda. Just provide your AWS credentials and your domain, and Bastion generate a BaaS for you using AWS infrastructure—setting up a backend can be completed quickly with minimal configuration. Unlike a managed solution, you will have full control of the code and data, but you will still be able to take advantage of the numerous services provided by one of the world’s leading cloud service providers. Furthermore, because Bastion integrates with AWS Lambda functions, you can easily modify the behavior of your backend to fit your specific use case. And because Bastion runs using AWS infrastructure, your backend will be able to automatically scale when more resources are required.

Figure 7. Comparison of Bastion to managed and self-hosted BaaS services.

3 Bastion Overview

Bastion encompasses several components that work together to provide a streamlined user experience. Here we provide a brief overview of these components, with more detailed usage instructions included in the ‘Using Bastion’ section below.

Figure 8. The four main components of Bastion are the Bastion CLI, the Admin Dashboard, the Bastion Server, and the Bastion Client SDK.

3.1 Bastion Server

The Bastion Server is the heart of the application, and it is responsible for most of the core BaaS functionality. Bastion Server allows users of Bastion to interact with their database collections using standard CRUD actions. Bastion consumers can also manage the users of their application, using session cookies to keep track of logged-in users. File storage functionality is also provided, allowing users to upload and store files in the cloud. Bastion users can also execute custom Cloud Code functions from the Bastion server, providing flexibility and customizability.

Figure 9. Bastion Server interacts with the Admin App and the Bastion SDK.

3.2 Bastion Software Development Kit (SDK)

The Bastion Software Development Kit (SDK) is an npm package that can be installed in your frontend code to facilitate communication with your Bastion Server. Once the SDK is configured, you will have access to a variety of built-in methods that make it easy to interact with your Bastion Server to accomplish common backend tasks. For example, to get all items in a database collection, simply run the sdk.db.getAllItems function, passing the collection name as an argument.

Figure 10. The functionality provided by the Bastion Client SDK.

3.3 Bastion Admin App

When designing Bastion, we wanted users with multiple projects to be able to deploy multiple Bastion Servers. The Bastion Admin App allows users to spin up a Bastion Server instance on demand, generating all of the necessary AWS infrastructure in a few minutes. The Admin App also includes a graphical user interface (GUI) to simplify the management of your Bastion Server instances.

Figure 11. The Admin App is comprised of the Admin Dashboard and the Admin Backend. The Admin Backend maintains metadata about each Bastion Server instance in its own database. The Admin Backend provisions and tears down Bastion Server instances.

Besides Bastion Server management, the Admin App provides other useful functionality. It allows users to create new collections for any Bastion Server instance, dynamically generating endpoints that can be accessed by the Bastion SDK. Users can also use the Admin App to create their custom Cloud Code functions that can be executed from the SDK.


3.4 Bastion Command-Line Interface (CLI)

The Bastion CLI is the entry point for using Bastion. Simply install the npm package on your machine, type bastion deploy, follow the prompts, and you will have a fully functional Admin App in your AWS account in a few minutes. The only prerequisites are having an authenticated installation of the AWS CLI and a domain hosted in your AWS account.

Figure 12. The Bastion CLI create AWS resources, including those for networking and security, and displays the username and password that you need to login to the Admin App.

4 Bastion Design Decisions

Now that we’ve reviewed the main components of Bastion, we can discuss some of the design decisions we made while implementing Bastion. One of our main goals with Bastion was to make it as flexible as possible. We looked at the current BaaS options and realized that it is relatively easy to outgrow many of them. One way that we made Bastion flexible was by using Docker containers for Bastion Server and for the Admin App.


4.1 Docker Containers

To understand Docker containers, it is important to understand what they are replacing. Before containers, one common option was to develop locally, usually on a developer’s personal machine. But one problem with this approach is that the local development environment will likely be configured differently than the server production environment or even a colleague’s development environment. For example, the local development environment could be running a different operating system than the production environment. This discrepancy makes the code less portable and can be the source of a myriad of subtle bugs when deploying locally developed code or when collaborating with a colleague.


One solution is to use a virtual machine, which is essentially a complete operating system that runs within your local operating system. Now the environment can more closely be matched to the production environment, your colleague’s environment, or any environment at all. This sounds like a great solution, and it is, but because virtual machines are an entire operating system running in software, they are resource-intensive.

Figure 13. Virtual Machines virtualize an entire Guest OS, whereas Containers share the Host OS kernel, allowing them to take up less space and consume fewer resources.

This is where containers come in. Containers are similar to virtual machines, but with a key difference: they don’t need to have their own operating system. Instead, they share the host operating system’s kernel through the container daemon. This means that containers consume far fewer resources than virtual machines, making them more portable and less expensive to run while still being configurable, isolated environments.


Early in the development process, we decided to use Docker containers for both Bastion Server and for the Admin App to make Bastion more flexible. First, the containers are portable, so it would be easier for a Bastion user to use Bastion Server in another context. For example, it makes modifying Bastion Server easier. For example, if a Bastion user wanted to modify the functionality of Bastion Server, they could pull the source code from our GitHub repository, make the desired changes, build a new container with the modified code, and then substitute the new container in AWS. Second, if a Bastion user wanted to use their own hardware to run Bastion instead of AWS, they could configure the Bastion Server containers to work with an open-source container orchestration tool like Kubernetes or Docker Swarm.


4.2 Container Orchestration

Using containers for our application meant deciding on how to manage, network, and organize those containers. Since we are using AWS for our infrastructure, this meant that we had to decide between Elastic Container Service (ECS) and Elastic Kubernetes Service (EKS).4 AWS advertises ECS as the simpler container orchestration tool—an opinionated solution for running containers at scale that decreases the number of decisions required for compute, network, and security configurations without sacrificing features. Alternatively, EKS is the more flexible solution, leveraging the massive open-source Kubernetes ecosystem to allow for fine-grained control over how the containers are deployed.

Figure 14. Comparison between AWS ECS and EKS.

While EKS provides more flexibility with regards to the diverse tools and resources of the Kubernetes ecosystem, it has a much steeper learning curve and requires more expertise to setup and maintain. Since we want to make Bastion simple to maintain and easily modified, we decided to employ ECS for our infrastructure.


Fargate vs EC2

There are two ways to run the containers on ECS: Fargate and EC2.5 With EC2, you are responsible for provisioning, configuring, and scaling your own cluster of EC2 instances for running the containers. You have to choose server types, decide when to scale your clusters, and optimize cluster packing. Fargate, on the other hand, is much simpler and there is much less configuration and maintenance — you only have to think about the containers.

Figure 15. When using ECS with EC2, you must assign tasks to each EC2 instance, whereas Fargate abstracts these concerns away.

While using EC2 provides more granular control over the infrastructure, we opted to use Fargate so that users of Bastion would have less maintenance overhead. It also makes it significantly easier for a Bastion consumer to modify Bastion—they just need to swap out containers using the AWS Console. Fargate also makes scaling easy relative to EC2, allowing Bastion to continue being functional even when the application gets popular enough to require horizontal scaling.


4.3 The DB Server

Now that we had decided to use ECS Fargate for container orchestration, we needed to decide how to persist application data. Specifically, for a Bastion Server instance, we needed to determine what kind of database to use. While relational databases are good for structured data and flexible queries, we quickly settled on using a NoSQL database because NoSQL databases have more flexible schema.6 We wouldn’t have to force users to define the schema and relationships of their databases before they use them, they could just create a collection and start manipulating their data right away. The flexibility and ease of use of NoSQL fit well with our goal of decreasing configuration and increasing development speed.


DynamoDB vs MongoDB

Having settled on a NoSQL database, we needed to determine which database to implement. Since we’re using AWS infrastructure, we could use DyanmoDB, a fully managed, scalable, high-performance key-value NoSQL database. Setup is relatively straightforward and maintenance is low. Another option would be to run MongoDB in a container. MongoDB is an extremely popular document NoSQL database that is relatively easy to use and setup.


We opted to use a MongoDB container running in ECS. We want Bastion to be flexible and portable, and we thought that using MongoDB would help us achieve those goals better than DynamoDB. By using MongoDB, we make it easy to develop and test Bastion locally, which would have been more difficult using DynamoDB. This would also make it easier for Bastion users to test modifications to the Bastion Server image locally before pushing them to AWS.


Persisting Data with Elastic File System (EFS)

Running a database Docker image in a container comes with some challenges. When data is written to disk in a container, it is stored on a writeable container layer, meaning that the data doesn’t persist when that container no longer exists.7 One solution is to use Docker Volumes to persist the data. When defining a Docker Volume for a container, the data in the specified directory gets persisted to the host machine. But this means that if our MongoDB container gets restarted on a different underlying host, all of the database data will be lost. To overcome this challenge, we use AWS Elastic File System (EFS).8 We mount EFS to the /data/db directory in our MongoDB container, so that MongoDB uses EFS when it reads or writes data, instead of the local, ephemeral container storage. Now, the data is persisted even if the container is restarted. This solution also means that we don’t have to worry about the size of our user’s data, since EFS will automatically grow and shrink as users add or remove data.

Figure 16. Elastic File System (EFS) gets mounted to the Docker container running MongoDB and allows data to persist even when the container is restarted with a different underlying host.

4.4 File Storage

We wanted users to be able to upload and store relatively large files. The maximum document size in MongoDB is 16MB, which would make storing larger files like high-resolution images impossible. However, it is possible to use GridFS with MongoDB to store larger files.9 The files are broken into chunks that each get stored as a separate document. Alternatively, AWS offers S3, which allows users to easily store any type of object. While using MongoDB and GridFS might be more portable to other contexts, we thought that using S3 would prove optimal because of its flexibility and ease of use. Specifically, we decided to upload files to a public-read S3 bucket and save the S3 URL in our database. When a user of Bastion wants to retrieve a file from their frontend code, then they can directly interact with the S3 bucket, reducing the load on the server.

Figure 17. Each Bastion Server gets its own public-read S3 bucket for file storage. When a client uploads a file, the S3 URL is saved to the database. Then, when the client requests that file, the S3 URL is returned. The client can then directly access the file from the S3 bucket.

4.5 The Admin App

Now we have the infrastructure in place for a single Bastion Server. The application code exists in a Docker container, which is run on ECS using Fargate. The application communicates with another Docker container running MongoDB in ECS Fargate, which is linked to EFS for data persistence. Finally, file storage is handled by S3.


We want Bastion consumers to be able to create multiple instances of Bastion Server, so we need an Admin App to manage them. To keep things consistent and not introduce any new technologies or services, we decided to also run the Admin App in a container in ECS Fargate.


Now, we have one container for our Admin App, one container for the Admin App’s MongoDB database, one container for each Bastion Server, and one container for each Bastion Server’s MongoDB database, and we need to keep them all organized.

Figure 18. The Admin App ECS Cluster uses one ECS Service to run one ECS Task that includes both the Admin Server and Admin DB Docker containers. In contrast, the Bastion Server ECS Cluster has separate ECS Services for the App Server and DB Server so that the App Server can scale independently of the DB Server.

We decided to use one ECS Cluster for the Admin App and one ECS Cluster for each Bastion Server instance. Within each Bastion Server ECS Cluster there is an ECS Service for the Bastion Server container and a separate ECS Service for the MongoDB container. We decided to keep the database and application server separate so that the application server could horizontally scale separately from the database. Since we aren’t concerned with scaling the Admin App, we have one ECS Service containing both the Admin App container and the Admin App database container—which, like the Bastion Server database container, also uses EFS to persist data.

Figure 19. Overview of the central AWS services used to deploy Bastion to the cloud.

4.6 Cloud Code

An important feature for Bastion is allowing users to upload and execute custom Cloud Code functions to improve the flexibility and extend the functionality of Bastion. One implementation option is to intake the user’s code and upload it to the desired Bastion Server instance. The code would then exist in a file on the server or as a document in the database. While this approach would be more portable to contexts other than AWS, it limits the power and flexibility of the Cloud Code function. For example, the custom code would only be able to use the npm packages already installed on our Bastion Server instance, which would limit the function’s capabilities.

Figure 20. The Admin App allows Bastion users to create and delete Cloud Code functions, while the client frontend applications execute the Cloud Code functions.

Another approach would be to use AWS Lambda functions. This would increase the flexibility of our Cloud Code functionality, allowing users to execute their custom functions in an isolated and customizable environment. This strategy would also lend itself to scaling, since AWS automatically handles resource provisioning for Lambda functions. The downside is that it ties a key Bastion feature to AWS, making it less portable to other contexts. We decided that the added flexibility of Lambda functions was worth the loss of portability, so we incorporated Lambda functions into Bastion.


5 Bastion Architecture

Figure 21. A more detailed view of the AWS services used to deploy Bastion to the cloud.

Now that we’ve decided on the basic AWS resources needed to build Bastion, we need to create a network where they can be deployed. First, we define a Virtual Private Cloud (VPC) where all of the Bastion resources live.10 The VPC is a logically isolated section of the AWS cloud that keeps Bastion separate from other AWS resources in your account. We made sure to allocate our Classless Inter-Domain Routing (CIDR) block so that there are many available IP addresses in the VPC. If a Bastion user needs to modify Bastion and allocate more AWS resources or expand into new Availability Zones, they won’t run out of IP addresses available to the VPC.

Figure 22. The Bastion VPC has approximately 65,500 IP addresses, so there are extra in case a Bastion user needs to add additional AWS resources in the future. By default, Bastion is deployed to one Availability Zone containing three subnets.

Within our VPC, we deploy each AWS resource in one of several subnets. A subnet is a sub-network in the VPC, representing a certain subset of the available IP addresses. To keep Bastion secure, we deploy our Admin App and Bastion Server instances in private subnets—subnets that are not accessible to the public internet. To make the Admin App and Bastion Server instances available to the public internet, we deploy an Application Load Balancer (ALB) in a public subnet that handles all incoming public traffic, routing requests to the appropriate AWS resource.

Figure 23. The compute resources are deployed to the App Tier private subnet, the Bastion Server databases are deployed to the DB Tier private subnet, and public-internet-facing components are deployed to the Web Tier public subnets.

Some of our AWS resources that are deployed in private subnets require public internet access for outgoing requests. For example, the Admin App needs to access a public Elastic Container Registry (ECR) to pull the Bastion Server image when creating a new Bastion Server instance. The Lambdas that we use for Cloud Code also need public internet access to integrate with third-party APIs. To solve this problem, we set up a Network Address Translation (NAT) gateway, which uses IP masquerading to route requests from resources in private subnets to the public internet.11


6 Bastion Implementation


6.1 Multi-Instance Architecture

One of our design goals was to allow Bastion users to create and use multiple Bastion Server instances, which made implementation more difficult than a single-instance architecture. One of the key challenges was determining how to programmatically provision the required AWS resources for both the Admin App and a Bastion Server instance. To solve this problem, we made extensive use of AWS CloudFormation, an AWS service that allows developers to deploy AWS infrastructure by treating infrastructure as code. When a Bastion user deploys the Admin App from the Bastion CLI, a CloudFormation script is executed, spinning up the necessary AWS resources. Our CloudFormation scripts make it quick and easy to provision and teardown AWS resources, making Bastion accessible for users that aren’t familiar with AWS.

Figure 24. When a user creates a Bastion Server from the Admin App, the Admin App executes a CloudFormation script that pulls the necessary Docker images from a public ECR. This process can be executed multiple times to create multiple Bastion Server instances.

Keeping Track of AWS Resources

The CloudFormation script in Bastion CLI provisions the AWS resources necessary for the Admin App. Additionally, the script creates many AWS resources that are used each time a Bastion Server instance is created.


For example, each Bastion Server instance is assigned an Identity and Access Management (IAM) Role that specifies what actions it can and cannot do.12 Instead of generating a new IAM Role each time we create a Bastion Server instance, we create a reusable IAM Role when we create the Admin App. Then, when a new Bastion Server is created, the Admin App needs access to the reusable IAM Role to assign it to the new Bastion Server. One option would be to use the AWS Node.js SDK to call the iam.getRole function,13 passing in the Role name as an argument, but this would require hard coding the Role name in the Admin App and making an API call. Instead, we directly pass the IAM Role into the Admin App as an environment variable in the CloudFormation script. This lets us avoid hard coding values in our code and avoid an unnecessary API call. We effectively used this strategy to keep track of many AWS resources in both the Admin App and the Bastion Server.

Figure 25. The Admin App ECS Task Definition in our CloudFormation script that specifies environment variables to make available to the Admin App once it is up and running. These environment variables are used to keep track of resources that get re-used when making new Bastion Server instances.

Communication Between AWS Resources

Now that the Admin App is deployed, we need to create a Bastion Server instance. Once again, we turn to a CloudFormation script, which deploys both the Bastion Server Docker image and the MongoDB Docker image, each in their own ECS Service. One challenge we faced in this process was determining the best way for these two components to communicate with each other. Specifically, how does the Bastion Server communicate with its database?


Both the Bastion Server and its database are deployed in their own ECS Service, so we need to connect two ECS Services. AWS recommends three different strategies for this.14 One option is to use an internal Load Balancer, which would server as a centralized location for managing all connections between each Service.


Another option is to use AWS App Mesh, which is a service mesh that manages a large number of services. Each container task gets an Envoy proxy sidecar that is responsible for proxying all inbound and outbound traffic.

Figure 26. AWS Cloud Map is a private DNS that allows AWS services to directly communicate with each other. When we create a Bastion Server instance, we assign it a new namespace and then assign each of its resources to a new subdomain, so each service has a unique URL that resolves to its private IP address.

The last option is to use service discovery using AWS Cloud Map, which allows for direct communication between containers. Cloud Map allows you to register container tasks with a custom name, which gets resolved to the internal IP address.


We wanted to avoid the extra infrastructure and complexity of the internal Load Balancer and app mesh, because they would lead to higher costs and increased maintenance, so we decided to use service discovery with AWS Cloud Map.


When a Bastion Server instance is created, it is given a name by the Bastion user. This name is then used to create a new private DNS namespace for the Bastion Server instance. For example, if Alice created a new Bastion Server named Wonderland, then this Bastion Server would be discoverable within AWS as app-server.Wonderland and its database would be discoverable as db.Wonderland. So connecting the Bastion Server to its database using mongoose would look something like this:

Figure 27. Example of using Cloud Map to connect to a database running on port 27017.

We also use service discovery in the Admin App to communicate with Bastion Server instances. The Admin App only needs to keep track of each Bastion Server’s unique name to communicate with it. Continuing with the Wonderland example from above, if the Admin App wanted to send a GET request to port 3001 using axios, it would look something like this:

Figure 28. Example of using axios to send a GET request from the Admin App to port 3001 of a Bastion Server instance.

Routing

With service discovery configured, the Admin App can now readily communicate with any Bastion Server instance, and each Bastion Server instance can communicate with its database. But this communication configuration only works within AWS. Now we need to determine how to route traffic from the public internet to the appropriate AWS resource. For example, if Alice has two Bastion Servers, one named Wonderland and one named TeaParty, how would she send a request to her Wonderland Bastion Server from an application running on her laptop? Our solution is to use route-based load balancing, where the route path of the request determines which AWS resource that the request is sent to. Since we host the Admin App and all Bastion Server instances behind a single Application Load Balancer (ALB), this means adding ALB Listener Rules.15


When a user creates a new Bastion Server instance, she gives it a name, and this name uniquely identifies that Bastion Server. We already saw how this is useful in a service discovery context, and it is also useful for routing. Each time a new Bastion Server is created, we create a new ALB Listener Rule in our CloudFormation script. This new Listener Rule routes any path that matches /server/:name/* to the appropriate Bastion Server. Using the same example as above, if Alice creates a Bastion Server named Wonderland, then all routes that match /server/Wonderland/*, would be routed to her newly created Bastion Server, where * is a catch-all wildcard. If she made another Bastion Server called TeaParty, then all routes that match /server/TeaParty/* would be sent to that server. Any requests that need to be sent to the Admin App would need to match /admin/*.


6.2 User Authentication

Now that Bastion Server instances are accessible to the public internet, we need to ensure that our user’s data is secure and only accessible to authenticated users. One strategy for user authentication is to use session cookies. When a user logs in, the response includes a session cookie that uniquely identifies that user. Then, in subsequent requests, that cookie is sent to the Bastion Server instance, which checks whether the session is still valid, and if it is, it returns the requested resource.

Figure 29. First, when a user successfully logs in, the server sends a session cookie that gets stored in the user’s browser. Then, when a user requests a resource, the session cookie is sent along with the request. If the cookie matches a valid session in the database, then the requested resource is returned.

An alternative strategy to using session cookies would be to use JSON web tokens (JWT) to keep track of user sessions.16 Upon successful login, the server generates an access token that stores the user’s ID and session expiration in an encrypted form. Then, for each subsequent request, the client sends the JWT, the server decrypts it, and then determines if the session is still valid. One advantage of this strategy is that the server doesn’t have to keep track of sessions because the session information is stored directly in the JWT. The tradeoff is that invalidating sessions becomes more complicated. When a user logs out with session cookies, the server just needs to remove that cookie from the session store. Since the JWT strategy doesn’t keep track of sessions in a session store, this isn’t possible. There are several workarounds, including deleting the token from the client, short token lifetimes, and JWT blacklists. Since one of our main goals is to keep Bastion simple to setup, maintain, and customize, and session cookies are more straightforward to implement, we decided to use the session cookie strategy.


Secure Cookies

To further secure Bastion, we decided to require HTTPS. Once a request from the public internet reaches the Application Load Balancer (ALB) we perform SSL offloading.17 In other words, because we’re using HTTPS, a request is encrypted until it reaches the ALB. Then, the ALB decrypts the request and forwards it using HTTP to the appropriate AWS resource within the Bastion VPC. If we didn’t perform SSL offloading at the ALB, then we would need to generate SSL certificates for the Admin app and each Bastion Server, which would be difficult to configure. Additionally, the encryption and decryption required at each network hop would increase the load on the servers, decreasing their performance.

Figure 30. HTTPS requests from the public internet are decrypted once they reach the Application Load Balancer, and then forwarded to the appropriate AWS service using HTTP in a process known as SSL offloading.

Using HTTPS enables us to further increase Bastion’s security by allowing us to use secure cookies for user sessions—cookies that are only transmittable over HTTPS. But using secure cookies presents a problem. Since we are performing SSL offloading, requests are converted from HTTPS to HTTP when they pass through the ALB. Since secure cookies must be sent over HTTPS, the user session cookies are dropped during SSL offloading. Thankfully, we were able to configure Bastion Server to overcome this problem. By configuring Express with app.set('trust proxy', 1), we tell our server that there is one proxy in front of it (our ALB) and that it can trust that proxy to pass secure cookies over HTTP.18


6.3 Bastion Server Data

Now that we’ve secured Bastion Server, we need functionality for manipulating the data. We want users of Bastion to be able to generate database collections and then perform standard CRUD operations on them, so we need a way to dynamically and programmatically generate database collections.


Figure 31. When a user creates a collection in the Admin App, the Admin App instructs the appropriate Bastion Server instance

In Express, server endpoints are often defined in a router. Typically, these endpoints are defined directly in a Node.js file with the path as the first argument, and the function to invoke as the second argument. So to have routes for a collection called items, we would want to create a router that looked like this:

Figure 32. Example of creating GET and POST server endpoints with the Express framework.

But if we generate the items collection while the server is running, we won’t be able to directly code those routes in a Node.js file like we did above. Our solution to this was to define an initially empty router object and then create a function that mutates this router, adding CRUD functionality when a new collection is made:

Figure 33. Part of our code for dynamically generating CRUD routes for database collections.

One problem with this strategy is that these dynamically generated routes only exist in memory, so they would disappear if the server needs to be restarted. To overcome this, on startup, the server scans the database for all collections and generates all of the necessary CRUD routes for each collection in the database.


6.4 Cloud Code

Besides data manipulation, another key feature of Bastion is Cloud Code—allowing users to define their own custom functions that can be called from their frontend code. As mentioned above, we decided to use AWS Lambda functions for Cloud Code, but we still need an implementation strategy. One option would be to use Docker containers.19 With this strategy, a Docker image would need to be created, uploaded to AWS Elastic Container Registry (ECR), and then a new Lambda function would be defined based on that container image. An alternative strategy would be to intake the custom Cloud Code function as a zip file, upload it to an S3 bucket, and then create a new Lambda function based on the zip file. We reasoned that uploading a zip file is a more straightforward process than generating a Docker image and pushing it to ECR, so we implementing Cloud Code using zip files and S3 to keep Bastion simple and user friendly.

Figure 34. Comparison of AWS Lambda vs Docker containers for implementing Cloud Code functions.

Users can upload their Cloud Code zip files through the Admin App, which then stores the zip file in the appropriate S3 bucket and registers a new AWS Lambda function based on that zip file. The Admin App then registers the new Lambda function with the appropriate Bastion Server instance, allowing it to be executed through the SDK.

Figure 35. Cloud Code functions are created, then uploaded as zip files through the Admin App to an S3 bucket. Then the Lambda is created from the zip file in the S3 bucket and associated with the appropriate Bastion Server instance.

7 Using Bastion


7.1 Bastion CLI

Pre-requisites

  1. You will need Node and npm installed on your machine.
  2. The AWS Command Line Interface (CLI) must be installed on your system and it must be configured using your AWS account credentials.
  3. You must have a domain that you own in your AWS Route 53. (You can transfer a domain from another registrar to Route 53).
Figure 36. Domains registered with other providers can be transferred to AWS Route 53 DNS Hosting.

Installation

To install the CLI, use npm install -g bastion-baas-cli.


bastion deploy

After executing this command, you will be prompted for the following information:

  1. The name you want to give your Admin App stack.
  2. The AWS region where you want to deploy Bastion.
  3. The name of the domain that you would like to use for Bastion. This must be a domain you own in AWS Route 53.
  4. The hosted zone ID of your Route 53 domain.
  5. The username that you would like to use to login to the Admin App.

After providing this information, the Admin App will be created in your AWS account and will be accessible at the domain that you provided once the AWS resources have been deployed.

Figure 37. Running the bastion deploy command.

bastion show

This command will show the domain that your Bastion can be reached at, and your Admin App username and password.


bastion destroy

You will be prompted to select an Admin App, and then the AWS resources associated with the selected Bastion Admin App will be deprovisioned.


7.2 Admin App

Figure 38. Viewing the API key for the ShoppingCart Bastion Server, and creating a ChatApp Bastion Server for a logged-in admin.

Before you can use the Admin App, you’ll need to login with the credentials provided when you initialized Bastion using the Bastion CLI. Once logged in, you’ll be presented with a list of your current Bastion Server instances. On this page you can create or destroy Bastion Server instances and view each Bastion Server’s unique API key.

Figure 39. The Admin App home page where you can view, create, and destroy your current Bastion Server instances.

If you want to manage a specific Bastion Server instance, you can click on it in the side panel. Once you do, you’ll be presented with a list of collections associated with that Bastion Server instance. Also, the side panel will change, allowing you to view and manage your users, files, and Cloud Code functions.

Figure 40. Navigating to a Bastion Server instance, viewing current Cloud Code functions, and creating a new Cloud Code function.

7.3 Bastion SDK

Figure 41. Sample code showing how a client would initialize the Bastion SDK to use in their frontend application.

Authentication

Method Description
sdk.auth.register(username, email, password) Creates a new user.
sdk.auth.login(username, password) Creates a session for the user using a secure cookie.
sdk.auth.logout() Ends the user’s session by invalidating the cookie.

Database

Method Description
sdk.db.getAllItems( collectionName ) Returns all items in a collection as a JSON object.
sdk.db.getItem( collectionName, itemId ) Returns a single item in a collection as a JSON object.
sdk.db.createItem( collectionName, data ) Returns a single item in a collection as a JSON object.
sdk.db.overwriteItem( collectionName, itemId, data ) Overwrites a database record with the passed-in JSON object. Only the key–value pairs in data are changed.
sdk.db.updateItem( collectionName, itemId, data ) Updates a database record with the passed-in JSON object. The entire record is replaced.
sdk.db.deleteItem( collectionName, itemId ) Deletes a database record.

Cloud Code

Method Description
sdk.ccf.run( cloudFunctionName, parameters ) Runs the specified Cloud Code function and returns the return value of the function.

File Storage

Method Description
sdk.storage.getAllFiles() Returns information about all files, each file is represented as a JSON object with the filename and S3 URL.
sdk.storage.getFile( fileId ) Returns information about all files, each file is represented as a JSON object with the filename and S3 URL.
sdk.storage.uploadFile( data, name ) Uploads a file to S3. The data expects the file as multipart/form-data.
sdk.storage.deleteFile( fileId ) Deletes a file.

8 Future Work


Whitelist Frontend Domains

Currently, any frontend domain that sends the correct API key has access to Bastion Server. We want to restrict access to Bastion Server to registered domains so that even if the API key fell into the wrong hands, it wouldn’t be usable by unregistered frontend domains.


More SDKs

Currently, the Bastion SDK is only available for JavaScript, but we would like to expand it and make an SDK available for Swift for iOS development and Kotlin for Android development.


Redis for Sessions

Currently, our session information is stored in MongoDB, but to optimize read and write speed we could utilize Redis for storing our session information.


Multiple Availability Zones

Currently, Bastion is deployed to one Availability Zone in AWS. To improve availability, we want to deploy Bastion to at least one more Availability Zone.


9 Team



Adam Trotta
Boston, MA
Alican Sungur
London, UK
Pavlo Artemenko
Madison, CT
Reilly Knutson
Boston, MA

10 References

  1. https://github.com/appwrite/appwrite/issues/307
  2. https://techcrunch.com/2017/01/30/facebooks-parse-developer-platform-is-shutting-down-today/
  3. https://devblogs.microsoft.com/appcenter/app-center-mbaas-retirement/
  4. https://aws.amazon.com/blogs/containers/amazon-ecs-vs-amazon-eks-making-sense-of-aws-container-services/
  5. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/application_architecture.html
  6. http://highscalability.com/blog/2010/12/6/what-the-heck-are-you-actually-using-nosql-for.html
  7. https://docs.docker.com/storage/volumes/
  8. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/using_data_volumes.html
  9. https://www.mongodb.com/docs/manual/core/gridfs/
  10. https://www.infoq.com/articles/aws-vpc-explained/#:~:text=as%20Availability%20Zones.-,Subnet,Zone%20and%20cannot%20span%20zones.
  11. https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-scenarios.html
  12. https://docs.aws.amazon.com/AmazonECS/latest/userguide/task-iam-roles.html
  13. https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/IAM.html#getRole-property
  14. https://docs.aws.amazon.com/AmazonECS/latest/bestpracticesguide/networking-connecting-services.html
  15. https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#listener-rules
  16. https://hackernoon.com/using-session-cookies-vs-jwt-for-authentication-sd2v3vci
  17. https://avinetworks.com/glossary/ssl-offload/
  18. https://expressjs.com/en/guide/behind-proxies.html
  19. https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-package.html