DEV Community

Cover image for Deploying a Multi-Module Spring Boot App to Render with PostgreSQL, Redis, Docker, and Flyway
Ojo Ilesanmi
Ojo Ilesanmi

Posted on

Deploying a Multi-Module Spring Boot App to Render with PostgreSQL, Redis, Docker, and Flyway

Deploying a Spring Boot backend should be simple in theory. Build the JAR, set the environment variables, connect the database, and ship it.

In practice, my deployment exposed several assumptions that worked locally but failed immediately in the cloud.

I recently deployed a modular Spring Boot application to Render using Docker, Render Blueprint, PostgreSQL, Redis, Flyway migrations, Spring profiles, Hibernate/JPA, and environment variables.

The application worked locally with MySQL and Redis, but deployment exposed several production-specific issues that were easy to miss in local development. This article documents the problems, why they happened, and how I fixed them properly.

Who This Article Is For

This article is useful if you are deploying a Spring Boot application to Render and your local setup uses MySQL, Redis, Flyway, Docker, or a multi-module Maven structure.

It is especially relevant if you are moving from a local MySQL setup to PostgreSQL in the cloud.

The Stack

The backend was a Java 17 Spring Boot application with multiple Maven modules:

alagbafo/
├── api-contracts
├── core
├── users
├── orders
├── payments
├── wallet
├── notifications
├── admin
├── subscriptions
└── app
Enter fullscreen mode Exit fullscreen mode

The app module was the actual Spring Boot entry point.

Locally, the project used MySQL and Redis:

spring.datasource.url=jdbc:mysql://localhost:3306/alagbafo
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.data.redis.host=localhost
spring.data.redis.port=6379
Enter fullscreen mode Exit fullscreen mode

For Render, the target setup was:

Spring Boot app
PostgreSQL database
Redis-compatible Key Value store
Docker deployment
Flyway migrations
Enter fullscreen mode Exit fullscreen mode

Render Blueprint was the best fit because it allowed the infrastructure to be described in a render.yaml file.

Step 1: Dockerfile for a Multi-Module Spring Boot App

Because the project was a multi-module Maven application, the Dockerfile had to copy all module pom.xml files before copying the source code.

This improves Docker layer caching because dependencies can be downloaded before the full source code is copied.

# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS build

WORKDIR /app

COPY pom.xml .
COPY api-contracts/pom.xml api-contracts/
COPY core/pom.xml core/
COPY users/pom.xml users/
COPY orders/pom.xml orders/
COPY payments/pom.xml payments/
COPY wallet/pom.xml wallet/
COPY delivery/pom.xml delivery/
COPY notifications/pom.xml notifications/
COPY admin/pom.xml admin/
COPY support/pom.xml support/
COPY packages/pom.xml packages/
COPY subscriptions/pom.xml subscriptions/
COPY app/pom.xml app/

RUN mvn dependency:go-offline -B

COPY . .
RUN mvn clean package -DskipTests -B

# Stage 2: Run
FROM eclipse-temurin:17-jre-jammy

WORKDIR /app

COPY --from=build /app/app/target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

This works well for Render because Render can build directly from the Dockerfile.

Step 2: The Render Blueprint

The deployment needed three resources:

  • A web service
  • A PostgreSQL database
  • A Redis service

The corrected render.yaml looked like this:

services:
  - type: web
    name: alagbafo
    runtime: docker
    dockerfilePath: ./Dockerfile
    plan: free
    envVars:
      - key: SPRING_PROFILES_ACTIVE
        value: render
      - key: JAVA_OPTS
        value: "-Xms256m -Xmx512m"
      - key: DATABASE_URL
        fromDatabase:
          name: alagbafo-db
          property: connectionString
      - key: REDIS_URL
        fromService:
          type: redis
          name: alagbafo-redis
          property: connectionString
      - key: JWT_SECRET
        generateValue: true
      - key: APP_BASE_URL
        value: "https://alagbafo.onrender.com"
      - key: PAYSTACK_SECRET_KEY
        sync: false
      - key: PAYSTACK_PUBLIC_KEY
        sync: false
      - key: PAYSTACK_WEBHOOK_SECRET
        sync: false

  - type: redis
    name: alagbafo-redis
    plan: free
    ipAllowList: []

databases:
  - name: alagbafo-db
    plan: free
    databaseName: alagbafo
    ipAllowList: []
Enter fullscreen mode Exit fullscreen mode

One important lesson: Redis does not belong under databases.

PostgreSQL goes under databases, while Redis goes under services with type: redis.

If Redis is declared incorrectly, Render will not inject REDIS_URL, and Spring Boot will fail at startup.

Step 3: Creating a Render-Specific Spring Profile

The first deployment failed with this:

Caused by: com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
Enter fullscreen mode Exit fullscreen mode

That meant the application was still using the default MySQL configuration.

The cause was simple: the Render Spring profile was not active.

The fix was to create a Render-specific profile:

# application-render.properties
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.flyway.locations=classpath:db/migration-pg
server.port=${PORT:8080}
Enter fullscreen mode Exit fullscreen mode

Then I made sure Render set this environment variable:

SPRING_PROFILES_ACTIVE=render
Enter fullscreen mode Exit fullscreen mode

Without this, Spring Boot keeps loading application.properties, which in my case pointed to MySQL.

Problem 1: Redis URL Was Empty

The next failure was:

The URL '' is not valid for configuring Spring Data Redis.
The scheme 'null' is not supported.
Use the scheme 'redis://' for insecure or 'rediss://' for secure Redis standalone configuration.
Enter fullscreen mode Exit fullscreen mode

This happened because the Redis service was declared incorrectly in render.yaml.

I initially put Redis under databases, which was wrong.

The corrected Redis service definition was:

services:
  - type: redis
    name: alagbafo-redis
    plan: free
    ipAllowList: []
Enter fullscreen mode Exit fullscreen mode

Then the web service can reference it:

- key: REDIS_URL
  fromService:
    type: redis
    name: alagbafo-redis
    property: connectionString
Enter fullscreen mode Exit fullscreen mode

In Spring Boot, the Render profile uses:

spring.data.redis.url=${REDIS_URL}
Enter fullscreen mode Exit fullscreen mode

Avoid using an empty fallback like this:

spring.data.redis.url=${REDIS_URL:}
Enter fullscreen mode Exit fullscreen mode

That makes debugging harder because Spring receives an empty string and fails with a confusing URL error.

Problem 2: Render PostgreSQL URL Is Not a JDBC URL

Render provides PostgreSQL URLs like this:

postgresql://user:password@host/database
Enter fullscreen mode Exit fullscreen mode

The PostgreSQL JDBC driver expects something like this:

jdbc:postgresql://host:5432/database
Enter fullscreen mode Exit fullscreen mode

At first, I tried this:

spring.datasource.url=jdbc:${DATABASE_URL}
Enter fullscreen mode Exit fullscreen mode

That produced a URL like this:

jdbc:postgresql://user:password@host/database
Enter fullscreen mode Exit fullscreen mode

It looked close, but it still failed:

Driver org.postgresql.Driver claims to not accept jdbcUrl
Enter fullscreen mode Exit fullscreen mode

The issue was that the JDBC URL should not include credentials in that URI format.

Spring/Hikari works better with this structure:

jdbc:postgresql://host:5432/database
username=user
password=password
Enter fullscreen mode Exit fullscreen mode

So I created a Render-only datasource config:

package com.alagbafo.core.config;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

import javax.sql.DataSource;
import java.net.URI;

@Configuration
@Profile("render")
@ConditionalOnProperty(name = "DATABASE_URL")
public class RenderDataSourceConfig {

    @Bean
    public DataSource dataSource(@Value("${DATABASE_URL}") String databaseUrl) {
        URI uri = URI.create(databaseUrl);

        String[] userInfo = uri.getUserInfo().split(":", 2);
        String username = userInfo[0];
        String password = userInfo.length > 1 ? userInfo[1] : "";

        int port = uri.getPort() == -1 ? 5432 : uri.getPort();
        String jdbcUrl = "jdbc:postgresql://" + uri.getHost() + ":" + port + uri.getPath();

        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(jdbcUrl);
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        dataSource.setDriverClassName("org.postgresql.Driver");
        return dataSource;
    }
}
Enter fullscreen mode Exit fullscreen mode

This solved the datasource issue cleanly.

Step 4: Adding PostgreSQL Dependencies

The project originally only had MySQL:

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

For Render, I added PostgreSQL:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Since Flyway was being used, I also added PostgreSQL database support:

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Problem 3: MySQL Flyway Migrations Do Not Run on PostgreSQL

The local migrations used MySQL syntax:

CREATE TABLE users_user (
    id BIGINT NOT NULL AUTO_INCREMENT,
    email VARCHAR(255) NOT NULL,
    created_at DATETIME(6) NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_users_user_email (email),
    KEY idx_user_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Enter fullscreen mode Exit fullscreen mode

PostgreSQL does not understand MySQL-specific syntax like:

AUTO_INCREMENT
DATETIME(6)
UNIQUE KEY
KEY
ENGINE=InnoDB
DEFAULT CHARSET
COLLATE=utf8mb4_unicode_ci
ON DUPLICATE KEY UPDATE
Enter fullscreen mode Exit fullscreen mode

So I created a separate migration folder for PostgreSQL:

core/src/main/resources/db/migration-pg
Enter fullscreen mode Exit fullscreen mode

Then the Render profile pointed Flyway there:

spring.flyway.locations=classpath:db/migration-pg
Enter fullscreen mode Exit fullscreen mode

A PostgreSQL-compatible version looks like this:

CREATE TABLE users_user (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    created_at TIMESTAMP(6) NOT NULL,
    CONSTRAINT uk_users_user_user_id UNIQUE (user_id),
    CONSTRAINT uk_users_user_email UNIQUE (email)
);

CREATE INDEX idx_user_created ON users_user (created_at);
Enter fullscreen mode Exit fullscreen mode

For MySQL upserts like this:

ON DUPLICATE KEY UPDATE
Enter fullscreen mode Exit fullscreen mode

PostgreSQL uses:

ON CONFLICT (code) DO UPDATE SET
    name = EXCLUDED.name,
    description = EXCLUDED.description;
Enter fullscreen mode Exit fullscreen mode

Problem 4: JSON vs JSONB vs Hibernate Validation

After database connectivity was solved, Hibernate schema validation failed:

Schema-validation: wrong column type encountered in column [new_value]
in table [admin_audit_log];
found [jsonb (Types#OTHER)], but expecting [json (Types#VARCHAR)]
Enter fullscreen mode Exit fullscreen mode

The entity had:

@Column(name = "new_value", columnDefinition = "JSON")
private String newValue;
Enter fullscreen mode Exit fullscreen mode

But the PostgreSQL migration had created:

new_value JSONB
Enter fullscreen mode Exit fullscreen mode

PostgreSQL supports both json and jsonb, but Hibernate was validating strictly against the entity definition.

I fixed the PostgreSQL migration:

old_value JSON,
new_value JSON
Enter fullscreen mode Exit fullscreen mode

Because the old migration had already run on Render, I added a corrective migration:

ALTER TABLE admin_audit_log
    ALTER COLUMN old_value TYPE JSON USING old_value::JSON,
    ALTER COLUMN new_value TYPE JSON USING new_value::JSON;

ALTER TABLE notifications
    ALTER COLUMN template_variables TYPE JSON USING template_variables::JSON;
Enter fullscreen mode Exit fullscreen mode

Problem 5: Hibernate Validation Can Be Too Strict for Deployment

In local development, this was useful:

spring.jpa.hibernate.ddl-auto=validate
Enter fullscreen mode Exit fullscreen mode

In a production-style environment with Flyway, I changed it to:

spring.jpa.hibernate.ddl-auto=none
Enter fullscreen mode Exit fullscreen mode

This is the better setup when Flyway owns schema management.

The final Render profile became:

# application-render.properties
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=none
spring.jpa.open-in-view=false

spring.flyway.locations=classpath:db/migration-pg
spring.flyway.validate-on-migrate=false

spring.data.redis.url=${REDIS_URL}
server.port=${PORT:8080}
Enter fullscreen mode Exit fullscreen mode

Flyway handles migrations. Hibernate runs the app. They do not fight each other.

Environment Variables on Render

These were required:

SPRING_PROFILES_ACTIVE=render
DATABASE_URL=auto-injected by Render PostgreSQL
REDIS_URL=auto-injected by Render Redis
JWT_SECRET=secure-generated-secret
Enter fullscreen mode Exit fullscreen mode

Paystack could be added later, but the app expected placeholders during setup:

PAYSTACK_SECRET_KEY=sk_test_dummy
PAYSTACK_PUBLIC_KEY=pk_test_dummy
PAYSTACK_WEBHOOK_SECRET=dummy
Enter fullscreen mode Exit fullscreen mode

For a secure JWT secret, generate one locally:

openssl rand -base64 48
Enter fullscreen mode Exit fullscreen mode

Final Checklist

If you are deploying a Spring Boot app to Render with Blueprint, PostgreSQL, Redis, Docker, and Flyway, check these:

  • Use New Blueprint, not just New Web Service.
  • Put PostgreSQL under databases.
  • Put Redis under services with type: redis.
  • Set SPRING_PROFILES_ACTIVE=render.
  • Do not use MySQL migrations for PostgreSQL.
  • Use separate Flyway folders for database-specific migrations.
  • Convert Render postgresql://... URL to JDBC format.
  • Let Flyway manage schema.
  • Set spring.jpa.hibernate.ddl-auto=none in deployment.
  • Add dummy values for optional third-party secrets if needed.
  • Keep real secrets in Render environment variables, not code.

Final Thoughts

The frustrating part was that every fix revealed the next layer.

First, MySQL was still being used. Then Redis was not injected. Then the PostgreSQL URL format was wrong. Then Flyway migrations needed PostgreSQL syntax. Then Hibernate schema validation complained about JSON types.

But each failure made the deployment more production-ready.

The key lesson is this: local development hides infrastructure assumptions, while cloud deployment exposes them.

Once the app had a proper Render profile, correct Blueprint config, PostgreSQL-specific Flyway migrations, and a datasource parser for Render’s database URL, the deployment became predictable.

That was the real win.

Top comments (0)