Published on

Getting Spring Boot, Docker and Redis working on Heroku

Authors

I was working on a project recently and needed to get the app running on a demo server as fast and easily as possible. Heroku delivered on this although I had to tweak a few things to get it working 100%.

Bare Bones Heroku Docker Setup

The bare minimum to get Heroku picking up your Dockerfile is:

  • heroku.yml
  • Dockerfile
  • Any environment variables you need

This should work for any type of project, not just Spring/Java.

My heroku.yml looks as follows:

setup:
  addons:
    - plan: Heroku-redis
      as: CACHE
build:
  docker:
    web: Dockerfile

Note:

  • If you have a heroku.yml file the Procfile is ignored. Hence no need for a Procfile
  • You can leave out the setup section if you have no addons.
    • In my case I have a Redis addon for cache management.

My Dockerfile is nothing special it looks as follows:

FROM gradle:6.3.0-jdk8 as builder

WORKDIR /home/builder
COPY . .

RUN gradle build -x test

FROM OpenJDK:8-alpine as runner

WORKDIR /home
COPY --from=builder /home/builder/build/libs/whatsApp-channel-0.0.1-SNAPSHOT.jar /home
ENTRYPOINT ["java","-jar","my-awesome-app-0.0.1-SNAPSHOT.jar"]

Note:

  • This uses multi-stage builds which work on Heroku. This eliminates the need for a docker registry to store your images.
  • The Dockerfile has to be in the root of your repo. This is not configurable on Heroku's side

For the environment variables:

  • Go to your app's "Settings" tab
  • Click "Reveal Config Vars"
  • Add environment variables with the name that your app is expecting. Associate any values you need to.

What was not clear to me was whether or not Heroku passes these to my app. The short answer is yes it does.

Heroku also exposes some special environment variables:

  • One per Heroku addon
    • In my case there was a URI to my Redis addon
  • PORT
    • This will not show up in your config section
    • This is the port Heroku expects your app to be exposed on.
    • You will need to update your app to use this port.
    • If you do not use this port Heroku gives you routing errors when trying to hit your app.

Spring Specific Changes

Initially I ran into the following error when the app started up:

Caused by: org.springframework.data.redis.RedisSystemException: Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: ERR Unsupported CONFIG parameter: notify-keyspace-events

To get this to work I had to update my Redis config to not do any startup configuration by returning the NO_OP operation. My full Redis config is below:

package com.example.app.config

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.cache.RedisCacheConfiguration.defaultCacheConfig
import org.springframework.data.redis.cache.RedisCacheManager
import org.springframework.data.redis.connection.RedisPassword
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.session.data.redis.config.ConfigureRedisAction
import java.net.URI


@Configuration
class SpringSessionRedisConfiguration {

    @Value("\${REDIS_URL}")
    private lateinit var redisUriString: String

    @Bean
    fun configureRedisAction(): ConfigureRedisAction? {
        return ConfigureRedisAction.NO_OP
    }

    data class RedisLoginDetails(val host: String, val port: Int, val password: String?)

    fun getRedisLoginDetails(redisToGoString: String): RedisLoginDetails {
        val uri = URI(redisToGoString)

        return RedisLoginDetails(
            host = uri.host,
            port = uri.port,
            password = uri.userInfo?.split(":")?.getOrElse(1) { "" } ?: ""
        )
    }


    @Bean
    fun jedisConnectionFactory(): JedisConnectionFactory {
        val (host, port, password) = getRedisLoginDetails(redisUriString)
        val standaloneConfiguration = RedisStandaloneConfiguration(host, port)
        standaloneConfiguration.password = RedisPassword.of(password)

        return JedisConnectionFactory(standaloneConfiguration)
    }

    @Bean
    fun redisTemplate(): RedisTemplate<Any, Any> {
        val redisTemplate = RedisTemplate<Any, Any>()
        redisTemplate.setConnectionFactory(jedisConnectionFactory())
        return redisTemplate
    }

    @Bean
    fun cacheManager(jedisConnectionFactory: JedisConnectionFactory): RedisCacheManager {
        return RedisCacheManager
            .builder(jedisConnectionFactory)
            .cacheDefaults(defaultCacheConfig())
            .transactionAware()
            .build()

    }
}

I configured Spring to use the environment variable PORT if it is present otherwise server.port and finally fallback to 8080 if none are present:

package com.example.app.config

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.web.server.ConfigurableWebServerFactory
import org.springframework.boot.web.server.WebServerFactoryCustomizer
import org.springframework.context.annotation.Configuration
import org.springframework.core.env.Environment


@Configuration
class ServerPortCustomizer : WebServerFactoryCustomizer<ConfigurableWebServerFactory> {

    @Autowired
    lateinit var environment: Environment

    override fun customize(factory: ConfigurableWebServerFactory) {
        val port: String? = environment.getProperty("PORT")
        val serverPort: String? = environment.getProperty("server.port")

        val portToUse = port?.toInt() ?: (serverPort?.toInt() ?: 8080)

        factory.setPort(portToUse)
    }
}

Resources