Navigate back to the homepage

How to make your multi-tenant Spring app production-ready

Johannes Matt
December 3rd, 2023 · 6 min read

Update December 2023

The original version of this post was published on October 10, 2022 using Spring Boot 2.7. The article has been updated for Spring Boot 3.

In the final part of this multi-tenant SaaS solution series, we will look at different tools and libraries that can help you make your multi-tenant Spring app ready for production.

If you haven't read the previous parts of this series, you can find them here:

Table of Contents

What does production-ready mean?

Before we talk about some tips and tricks to make your app ready for production, let's first have a look at what production readiness means.

Many software applications start as small experiments, internal tools or simply don't have the goal of being made available to an outside customer base. However, if we do have the goal of providing a high-quality solution to a broad range of customers and these customers rely on the functionality of our solution in their daily business, we need to ensure that our solution is designed and implemented according to industry standards and that we are using state-of-the-art technologies to build it. After all, we want to ensure that our customers are happy, and that they can rely on a robust application that solves some critical business problems for them.

That's why a production-ready solution should be:

  • scalable: The architecture should be well-designed by building a robust solution that serves our customers by fulfilling their expected quality and performance requirements
  • maintainable: Adding new features and refactoring existing components of our system should be easily possible
  • secure: Our application should use state-of-the-art security protocols which ensure that our customers' data stays protected
  • extensible: To stay open to unpredictable changes, our app should not rely on unverified assumptions which limit the evolution of our architecture, but should rather be designed with a flexible approach from the ground up

There are definitely many more requirements that could be added to this list. Some important aspects to consider for most applications are monitoring, logging, tracing, automation of CI/CD pipelines, and many more.

However, for the sake of this post, we will focus on the points from the list above to present some tools and libraries that help you fulfill the mentioned requirements.

Tools & Libraries


Using state-of-the-art security is a must-have for every business application. By using OAuth2, we can rely on an industry-proven standard to ensure that our customers' data stays protected and can only be accessed by authorized users.

With the OAuth2 terminology, our multi-tenant application acts as a resource server. The main responsibility of our app is to provide secure access to protected resources.

Since we want to build a loosely coupled system, we set up a dedicated authorization server which users can contact to obtain OAuth2 tokens that can be used to access data protected by the resource server. Alternatively, it would also be possible to combine the functionalities of an authorization server and a resource server in a single app. However, for this post we will look at how to set up and use a standalone authorization server in order to decouple the authentication concern from our core app.


One of the best open-source identity providers (IDP) is Keycloak. Keycloak can be set up with minimal effort, and it is highly configurable. It can act as an OAuth2 authorization server and additionally supports OpenID Connect, an extension to OAuth2 that adds authentication and user identity features on top of OAuth2.


To set up Keycloak for a multi-tenant app, see the following article with a detailed description of the required steps: Setting up Keycloak for a multi-tenant app


Once both tenants are set up, we can obtain OAuth2 tokens for the individual users from Keycloak. These tokens can then be used to access the resources that are protected by our multi-tenant application.

We can obtain an access token by sending the following HTTP POST request to the token URL of our tenant:

1curl --request POST \
2 --url http://localhost:9090/realms/tenant-1/protocol/openid-connect/token \
3 --header 'Content-Type: application/x-www-form-urlencoded' \
4 --data client_id=app \
5 --data grant_type=password \
6 --data username=alice \
7 --data password=alice

We can afterwards use the received access token to authorize our requests that we send to the multi-tenant app.

Let's look at how we can integrate Keycloak with our multi-tenant app. We will use the same example as in part 3.


The example app we started in part 3 exposes a /users API to get a list of all users for a specific tenant. In part 3, we demonstrated how resolving the tenant works based on a custom HTTP header. In this article, we will improve the security of our app by using OAuth2 instead of a simple HTTP header for tenant resolution.

First, let's update our file to use JWT resolution:\
4 io.quantics.multitenant.oauth2.config.KeycloakRealmAuthoritiesConverter

As you can see, we also define a converter to be used for mapping the JWT to granted authorities based on which the application can control the access level of individual users. Quantics' starter library already comes with a pre-defined KeycloakRealmAuthoritiesConverter. If you are using a different authorization server, you can also implement your own converter by extending AbstractJwtGrantedAuthoritiesConverter.

The next step is to specify the OAuth2 issuers that are used by the different tenants so that our app can map incoming requests to the corresponding tenants in our whitelist by using the OAuth2 issuer claim.

1UPDATE tenant
2SET issuer = 'http://localhost:9090/realms/tenant-1' WHERE id = 'tenant1';
4UPDATE tenant
5SET issuer = 'http://localhost:9090/realms/tenant-2' WHERE id = 'tenant2';

Now we have everything set up to verify the correct Keycloak integration. Let's get an access token for user alice and use the token to send a request to the /users endpoint:

1TOKEN=$(curl --request POST \
2 --url http://localhost:9090/realms/tenant-1/protocol/openid-connect/token \
3 --header 'Content-Type: application/x-www-form-urlencoded' \
4 --data client_id=app \
5 --data grant_type=password \
6 --data username=alice \
7 --data password=alice | jq -r '.access_token')
9curl --request GET \
10 --url http://localhost:8080/users \
11 --header "Authorization: Bearer ${TOKEN}"

Et voilà - we get the list of tenant 1 users in the response:

2 {
3 "name": "alice"
4 },
5 {
6 "name": "alex"
7 }

Equally, if we get a token for a tenant 2 user from Keycloak, we receive the tenant 2 users from our API. Request:

1TOKEN=$(curl --request POST \
2--url http://localhost:9090/realms/tenant-2/protocol/openid-connect/token \
3--header 'Content-Type: application/x-www-form-urlencoded' \
4--data client_id=app \
5--data grant_type=password \
6--data username=bob \
7--data password=bob | jq -r '.access_token')
9curl --request GET \
10--url http://localhost:8080/users \
11--header "Authorization: Bearer ${TOKEN}"


2 {
3 "name": "bob"
4 },
5 {
6 "name": "bella"
7 }

This all works with minimal configuration because Quantics' Multi-tenant OAuth2 Spring Boot starter library takes care of the whole authorization process by checking if the JWT is valid and routing each incoming request to the corresponding tenant. This way, we can keep our app clean and focus on implementing our business logic to make our customers happy.

Note: If you have trouble setting up this integration, is a great tool that can help you troubleshoot potential issues by decoding the token and inspecting its contents.


If APIs are integral to your solution, there is no way around fully documenting all endpoints including the expected request and response formats. Swagger is a tool that automatically documents all available API endpoints and exposes this documentation as an HTML page so that it can be easily consumed by users of the API. It implements the OpenAPI specification, which is a standard for RESTful APIs.

We can add OpenAPI and Swagger to our application by adding the following dependency in our pom.xml file:

2 <dependency>
3 <groupId>org.springdoc</groupId>
4 <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
5 <version>2.2.0</version>
6 </dependency>

Since we have protected our API endpoints with Spring Security, we need to whitelist the Swagger endpoints so that they can be accessed by unauthenticated users:

2public class SecurityConfig {
4 @Bean
5 public SecurityFilterChain filterChain(HttpSecurity http,
6 AuthenticationManagerResolver<HttpServletRequest> authManagerResolver)
7 throws Exception {
9 http
10 .authorizeHttpRequests(requests -> requests
11 // error endpoints
12 .requestMatchers("/error").permitAll()
14 // swagger resources
15 .requestMatchers(
16 "/swagger-resources/**",
17 "/swagger-ui.html",
18 "/swagger-ui/**",
19 "/v3/api-docs/**",
20 "/webjars/**"
21 ).permitAll()
23 // authenticate every request
24 .anyRequest().authenticated())
26 .oauth2ResourceServer(oauth2 -> oauth2
27 .authenticationManagerResolver(authManagerResolver));
29 return;
30 }

Now we can go to http://localhost:8080/swagger-ui/index.html and see the auto-generated API documentation of our endpoints. Pretty nice and easy, right?

Swagger UI


Most applications that run in production rely on a database to store and retrieve information. This can be a traditional SQL database, a NoSQL database such as a graph or time-series database, or even a combination of multiple databases. With ongoing improvements to your application, the database schema also evolves. This is usually necessary in order to implement new features or refactor your existing data structure.

For SQL and hybrid (multi-model) databases built on top of SQL, Flyway is a great tool for managing schema changes in your DB. It integrates directly into your build & deployment process and makes DB migrations easy. By maintaining the DB migrations in your Git repository, Flyway helps you to ensure that new code is always shipped together with the correct schema.

In order to use Flyway in our app, we need to add the following dependency to our pom.xml file:

2 <dependency>
3 <groupId>org.flywaydb</groupId>
4 <artifactId>flyway-core</artifactId>
5 </dependency>

Since our DB contains both a collection of tables that are common across all tenants (in the default, e.g. public schema) as well as a set of tables per tenant (inside the tenantX schema), we need to separate the migration scripts into two folders. We create two beans: one common bean for tenant-independent migrations and one tenant-specific bean that executes the migration scripts for each tenant schema.

2@ConditionalOnProperty(prefix = "spring", name = "flyway.enabled", matchIfMissing = true)
3public class FlywayConfig {
5 private final boolean outOfOrder;
6 private final boolean baselineOnMigrate;
8 public FlywayConfig(@Value("${spring.flyway.out-of-order:false}") boolean outOfOrder,
9 @Value("${spring.flyway.baseline-on-migrate:false}") boolean baselineOnMigrate) {
10 this.outOfOrder = outOfOrder;
11 this.baselineOnMigrate = baselineOnMigrate;
12 }
14 @Bean
15 public Flyway flyway(DataSource dataSource) {
16 Flyway flyway = Flyway.configure()
17 .outOfOrder(outOfOrder)
18 .baselineOnMigrate(baselineOnMigrate)
19 .locations("db/migration/default")
20 .dataSource(dataSource)
21 .schemas(CurrentTenantResolver.DEFAULT_SCHEMA)
22 .load();
23 flyway.migrate();
24 return flyway;
25 }
27 @Bean
28 @ConditionalOnProperty(prefix = "spring", name = "", havingValue = "false",
29 matchIfMissing = true)
30 public Boolean tenantsFlyway(SchemaTenantDetailsService tenantService, DataSource dataSource) {
31 tenantService.getAll().forEach(tenant -> {
32 Flyway flyway = Flyway.configure()
33 .outOfOrder(outOfOrder)
34 .baselineOnMigrate(baselineOnMigrate)
35 .locations("db/migration/tenants")
36 .dataSource(dataSource)
37 .schemas(tenant.getSchema())
38 .load();
39 flyway.migrate();
40 });
41 return true;
42 }

As you can see in the config, the bean for common migrations uses the location db/migration/default, whereas the tenant-specific migration scripts are sourced from db/migration/tenants.

Note: If we now start the application after adding the FlywayConfig class, Flyway will warn us that the schemas are not empty. This is because Flyway expects each schema to be empty the first time it is executed on this schema so that it can create the flyway_schema_history table. Since we have already created the tables in the public, tenant1, and tenant2 schema manually, we need to add the following setting to our


With this config, we are all set for running our solution in a production environment. Flyway will create its baseline of each schema and record every future change in the flyway_schema_history table. We can continuously add new features and refactor existing functionalities. All we need to ensure is that the schemas are migrated when there are structural changes in the DB. This is done by simply adding the migration scripts as .sql files into the configured directories. Then, Flyway takes care of running the migrations on application startup so that the DB schema is compatible with the state of our codebase at any time.


With the presented tools and libraries in this article, you can make your multi-tenant Spring application ready for production. Obviously, the presented tools are just a selection of many great tools out there. All the tools from this article are open source and can be used for free. Of course, there are many more tools that might be relevant for you. So feel free to let me know of any additional tools or libraries that you would like to read about.

The code from this article is on GitHub.


More articles from Johannes Matt

Building a multi-tenant SaaS solution: A Spring Boot sample app

This post shows a sample multi-tenant app built with Spring Boot 3, Spring Data JPA & Hibernate 6

November 28th, 2023 · 3 min read

Setting up Keycloak for a multi-tenant app

Setting up Keycloak as an IDP for a multi-tenant software solution

October 11th, 2022 · 1 min read
© 2022–2023 Johannes Matt
Link to $ to $