A common pattern when scaling a SaaS application is to deploy your stack to multiple regions across the globe. Deploying to multiple regions helps distribute the application and data closer to the user, and isolates infrastructure in case of an outage in a single region. If your application is deployed in this configuration, you have the new problem of routing users to the correct region based on their tenancy or geography. This post shows how to create a global login portal that directs users to the correct local region where they can access their application.

For the purposes of this article, our application can be simplified into a deployment that focuses on only the core details.

A simplified multi-region deployment

In this deployment, each region has a unique DNS name in Route53. DNS is configured to resolve to an application load balancer in a specific AWS region, and the load balancer directs traffic to the associated Kubernetes cluster through Nginx ingress pods. Each of the Kubernetes services that requires persistent data uses a regional deployment of Aurora or DynamoDB.

With this design, each regional deployment is completely independent. Based on the domain name you use, you will be directed to a unique region with a unique set of data. Users in Tokyo login with the tokyo.sookochef.com domain name, and users in Ireland login with the ireland.sookocheff.com, and each work on their own data completely separately.

To improve user experience as we roll out additional regions, we would prefer a design with a single domain name (say app.sookocheff.com) that could route to one out of a set of regions. This would also help with disaster recovery: a single domain name and login experience can direct users to a failover region when required.

Architecture Overview

To accomplish our goals, we will use a system with the architecture depicted in this diagram.

Logging in using geolocation-based routing

The system deploys a simple traffic management application to every region, and routes requests to that application by geolocation — the user is routed to the traffic management application in the region closest to the application. This access point is behind the global domain name app.sookocheff.com.

From a user perspective, our simplified example requires you to select your “home” region when you sign up — this is where your primary data resides. On subsequent login attempts, we retrieve the region that you created your account in, and either log the user in to the current region, or redirecting the user to the correct region that they signed up in. In the majority of cases, geolocation load balancing will direct the user to the region they signed up in. But in cases where a user is on vacation or working remotely from a different geolocation, they will be redirected to the correct location to login.

The key to making this work is that each instance of the login application must know about the users that have signed up in different regions. This means that the data backing the login application needs to use an active-active model where any data committed in one region needs to be replicated to all regions. This is a key distinction from the rest of the application where data is resident to the region.

Building the System

I built a prototype of this system using DynamoDB Global Tables and the Flask web framework. The login application is fairly simple: you can sign up for an account, specifying your home region. The call to users.add_user adds the required data to a DynamoDB Global Table. See Disaster Recovery with DynamoDB Global Tables for more on how to create and use the DynamoDB table.

@app.route('/signup', methods=['POST'])
def signup():
    if request.method == 'POST':
        email = request.form['email']
        password = request.form['password']
        region = request.form['region']

        users.add_user(email, password, region)

        return render_template('login.html',
                               msg="Signup Complete. Login to your account!")

    # else render index
    return render_template('index.html')

Once the user is added, Dynamo Global Tables will replicate the data to the configured regions. Logging in to the application is now fairly simple: the call users.passcheck will verify that an entry in the Dynamo table with the correct email exists, and that the password in that table matches the password given by the user. If there is a match, and we are in the wrong region, we redirect to the correct login page. If there is a match, and we are in the correct region, we render the home page.

@app.route('/login', methods=['POST'])
def check_password():
    email = request.form['email']
    password = request.form['password']
    region = users.passcheck(email, password)

    # Account does not exist
    if not region:
        return render_template('index.html',
                               msg="Access denied. Incorrect username/password combination.")

    # We are in the wrong region, redirect
    if region != dynamo_region:
        if region == REGION_EAST:
            return redirect(f"{HOST_EAST}/login", code=301)
        if region == REGION_WEST:
            return redirect(f"{HOST_WEST}/login", code=301)

    # Success! We have a password match, and we are in the correct region
    return render_template('home.html', region=dynamo_region)

Now the last step is to set your Route53 routing to use geolocation. Geolocation routing makes it possible to load-balance users load across endpoints in a predictable, easy-to-manage way, so that each user location is consistently routed to the same endpoint. For the vast majority of use cases, this works out of the box for routing users to the correct region. In the case where the user is directed incorrectly, we use the data stored in Dynamo Global Tables to route the user to the correct region to complete their work.