I’ve been a web developer for years, but I haven’t touched Java in a long time — like, late-90s long. Back then, Java development felt cumbersome: lots of boilerplate and complex configurations. It was not exactly a pleasant experience for building simple web apps. So, when I recently started exploring Scala and the Play Framework, I was curious more than anything. Has the Java developer experience gotten better? Is it actually something I’d want to use today?
Scala runs on the Java Virtual Machine, but it brings a more expressive, modern syntax to the table. It’s often used in backend development, especially when performance and scalability matter. Play is a web framework built for Scala, designed to be fast, reactive, and developer-friendly. And you use sbt, which is Scala’s build tool — roughly comparable to Maven or Gradle in the Java world.
In this post, I’ll walk through setting up a basic Scala Play app, running it locally, and then deploying it to Heroku. My hope is to show you how to get your app running smoothly — without needing to know much about the JVM or how Play works under the hood.
Introducing the Example App
To keep things simple, I’m starting with an existing sample project: the Play Scala REST API example. It’s a small application that exposes a few endpoints for creating and retrieving blog posts. All the data is stored in memory, so there’s no database to configure — perfect for testing and deployment experiments.
To make things easier for this walkthrough, I’ve cloned that sample repo and made a few tweaks to prep it for Heroku. You can follow along using my GitHub repo. This isn’t a production-ready app, and that’s the point. It’s just robust enough to explore how Play works and see what running and deploying a Scala app actually feels like.
Taking a Quick Look at the Code
Before we deploy anything, let’s take a quick tour of the app itself. It’s a small codebase, but there are a few things worth pointing out — especially if you’re new to Scala or curious about how it compares to more traditional Java. I learned a lot about the ins and outs of the Play Framework for building an API by reading this basic explanation page. Here are some key points:
Case Class to Model Resources
For this REST API, the basic resource is the blog post, which has an id
, link
, title
, and body
. The simplest way to model this is with a Scala case class that looks like this:
case class PostResource(
id: String,
link: String,
title: String,
body: String
)
This happens at the top of app/v1/post/PostResourceHandler.scala
(link).
Routing Is Clean and Centralized
The conf/routes
file maps HTTP requests to a router in a format that’s easy to read and change. It feels closer to something like Express or Flask than an XML-based Java config. In our example, the file is just one line:
-> /v1/posts v1.post.PostRouter
From there, the router at app/v1/post/PostRouter.scala
(link) defines how different requests within this path map to controller methods.
override def routes: Routes = {
case GET(p"/") =>
controller.index
case POST(p"/") =>
controller.process
case GET(p"/$id") =>
controller.show(id)
}
This is pretty clear. A GET
request to the path root takes us to the controller’s index
method, while a POST
request takes us to its process
method. Meanwhile, a GET
request with an included blog post id
will take us to the show
method and pass along the given id
.
Controllers are Concise
That brings us to our controller, at app/v1/post/PostController.scala
(link). Each endpoint is a method that returns an Action
, which works with the JSON result using Play’s built-in helpers. For example:
def show(id: String): Action[AnyContent] = PostAction.async {
implicit request =>
logger.trace(s"show: id = $id")
postResourceHandler.lookup(id).map { post =>
Ok(Json.toJson(post))
}
}
There’s very little boilerplate — no need for separate interface declarations or verbose annotations.
JSON is Handled With Implicit Values
Play uses implicit values to handle JSON serialization and deserialization. You define an implicit Format
[PostResource
] using Play JSON’s macros, and then Play just knows how to turn your objects into JSON and back again. No manual parsing or verbose configuration needed. We see this in app/v1/post/PostResourceHandler.scala
(link):
object PostResource {
/**
* Mapping to read/write a PostResource out as a JSON value.
*/
implicit val format: Format[PostResource] = Json.format
}
Modern, Expressive Syntax
As I dig around through the code, I see some of Scala’s more expressive features in action — things like map operations and pattern matching with match. The syntax looks new at first, but it quickly feels like a streamlined blend of Java and JavaScript.
Running the App Locally
Before deploying to the cloud, it’s always a good idea to make sure the app runs locally. This helps us catch any obvious issues and lets us poke around the API.
On my machine, I make sure to have Java and sbt
installed.
~/project$ java --version
openjdk 17.0.14 2025-01-21
OpenJDK Runtime Environment (build 17.0.14+7-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.14+7-Ubuntu-120.04, mixed mode, sharing)
~/project$ sbt --version
sbt version in this project: 1.10.6
sbt script version: 1.10.6
Then, I run the following from my project root:
~/project$ sbt run
[info] welcome to sbt 1.10.6 (Ubuntu Java 17.0.14)
[info] loading settings for project scala-api-heroku-build from plugins.sbt...
[info] loading project definition from /home/alvin/repositories/devspotlight/heroku/scala/scala-api-heroku/project
[info] loading settings for project root from build.sbt...
[info] loading settings for project docs from build.sbt...
[info] __ __
[info] \ \ ____ / /____ _ __ __
[info] \ \ / __ \ / // __ `// / / /
[info] / / / /_/ // // /_/ // /_/ /
[info] /_/ / .___//_/ \__,_/ \__, /
[info] /_/ /____/
[info]
[info] Version 3.0.6 running Java 17.0.14
[info]
[info] Play is run entirely by the community. Please consider contributing and/or donating:
[info] https://www.playframework.com/sponsors
[info]
--- (Running the application, auto-reloading is enabled) ---
INFO p.c.s.PekkoHttpServer - Listening for HTTP on /[0:0:0:0:0:0:0:0]:9000
(Server started, use Enter to stop and go back to the console...)
With my local server up and listening on port 9000
, I open a new terminal and test the API by sending a request:
$ curl http://localhost:9000/v1/posts | jq
[
{
"id": "1",
"link": "/v1/posts/1",
"title": "title 1",
"body": "blog post 1"
},
{
"id": "2",
"link": "/v1/posts/2",
"title": "title 2",
"body": "blog post 2"
},
{
"id": "3",
"link": "/v1/posts/3",
"title": "title 3",
"body": "blog post 3"
},
{
"id": "4",
"link": "/v1/posts/4",
"title": "title 4",
"body": "blog post 4"
},
{
"id": "5",
"link": "/v1/posts/5",
"title": "title 5",
"body": "blog post 5"
}
]
Nice. That was fast. Next, I want to play around with a few requests — retrieving and creating a blog post.
$ curl http://localhost:9000/v1/posts/3 | jq
{
"id": "3",
"link": "/v1/posts/3",
"title": "title 3",
"body": "blog post 3"
}
$ curl -X POST \
--header "Content-type:application/json" \
--data '{ "title": "Just Another Blog Post", "body": "This is my blog post body." }' \
http://localhost:9000/v1/posts | jq
{
"id": "999",
"link": "/v1/posts/999",
"title": "Just Another Blog Post",
"body": "This is my blog post body."
}
Preparing for Deployment to Heroku
Ok, we’re ready to deploy our Scala Play app to Heroku. However, being new to Scala and Play, I’m predisposed to hitting a few speed bumps. I want to cover those so that you can steer clear of them in your development.
Understanding the AllowedHostsFilter
When I run my app locally with sbt run
, I have no problems sending curl requests and receiving responses. But as soon as I’m in the cloud, I’m using a Heroku app URL, not localhost
. For security, Play has an AllowedHostsFilter enabled, which means you need to specify explicitly which hosts can access the app.
I modify conf/application.conf
to include the following block:
play.filters.hosts {
allowed = [
"localhost:9000",
${?PLAY_ALLOWED_HOSTS}
]
}
This way, I can set the PLAY_ALLOWED_HOSTS
config variable for my Heroku app, adding my Heroku app URL to the list of allowed hosts.
Setting a Play Secret
Play requires an application secret for security. It’s used for signing and encryption. You could set the application secret in conf/application.conf
, but that hardcodes it into your repo, which isn’t a good practice. Instead, let’s set it at runtime based on an environment config variable. We add the following lines to conf/application.conf
:
play.http.secret.key="changeme"
play.http.secret.key=${?PLAY_SECRET}
The first line sets a default secret key, while the second line sets the secret to come from our config variable, PLAY_SECRET
, if it is set.
sbt Run Versus Running the Compiled Binary
Lastly, I want to talk a little bit about sbt run
. But first, let’s rewind: on my first attempt at deploying to Heroku, I thought I would just spin up my server by having Heroku execute sbt run
. When I did that, my dyno kept crashing. I didn’t realize how memory-intensive this mode was. It’s meant for development, not production.
During that first attempt, I turned on log-runtime-metrics for my Heroku app. My app was using over 800M in memory — way too much for my little Eco Dyno, which was only 512M. If I wanted to use sbt run
, I would have needed a Performance-M dyno. That didn’t seem right.
As I read Play’s documentation on deploying an application, I realized that I should run my app through the compiled binary that Play creates via another command, sbt stage
. This version of my app is precompiled and much more memory efficient — down to around 180MB. Assuming sbt stage
would run first, I just needed to modify my startup command to run the binary.
Why I Went With Heroku
I chose Heroku for running my Scala app because it removes a lot of the setup friction. I don’t need to manually configure a server or install dependencies. Heroku knows that this is a Scala app (by detecting the presence of project/build.properties and build.sbt files) and applies its buildpack for Scala. This means automatic handling of things like dependency resolution and compilation by running sbt stage
.
For someone just experimenting with Scala and Play, this kind of zero-config deployment is ideal. I can focus on understanding the framework and the codebase without getting sidetracked by infrastructure. Once I sorted out a few gotchas from early on — like the AllowedHostsFilter
, Play secret, and startup command — deployment was quick and repeatable.
Alright, let’s go!
Deploying the App to Heroku
For deployment, I have the Heroku CLI installed, and I authenticate with heroku login
. My first step is to create a new Heroku app.
~/project$ heroku apps:create scala-blog-rest-api
Creating ⬢ scala-blog-rest-api... done
https://scala-blog-rest-api-5d26d52bd1e4.herokuapp.com/ | https://git.heroku.com/scala-blog-rest-api.git
Create the Procfile
Next, I need to create the Procfile
that tells Heroku how to spin up my app. In my project root, I create the file, which contains this one line:
web: target/universal/stage/bin/play-scala-rest-api-example -Dhttp.port=${PORT}
Notice that the command for starting up this Heroku web process is not sbt ru
n. Instead, we execute the binary found in target/universal/stage/bin/play-scala-rest-api-example
. That’s the binary compiled after running sbt stage
. Where does the play-scala-rest-api-example
file name come from? This is set in build.sbt
. Make sure that name is consistent between build.sbt
and your Procfile
.
Also, we set the port dynamically at runtime to the value of the environment variable PORT
. Heroku sets the PORT
environment variable when it starts up our app, so we just need to let our app know what that port is.
Specify the JDK Version
Next, I create a one-line file called system.properties
, specifying the JDK version I want to use for my app. Since my local machine uses openjdk 17, my system.properties
file looks like this:
java.runtime.version=17
Set Heroku App Config Variables
To make sure everything is in order, we need to set a few config variables for our app:
PLAY_SECRET
: A string of our choosing, required to be at least 256 bits.PLAY_ALLOWED_HOSTS
: Our Heroku app URL, which is then included in theAllowedHostsFilter
used inconf/application.conf
.
We set our config variables like this:
~/project$ heroku config:set \
PLAY_SECRET='ga87Dd*A7$^SFsrpywMWiyyskeEb9&D$hG!ctWxrp^47HCYI' \
PLAY_ALLOWED_HOSTS='scala-blog-rest-api-5d26d52bd1e4.herokuapp.com'
Setting PLAY_SECRET, PLAY_ALLOWED_HOSTS and restarting ⬢ scala-blog-rest-api... done, v2
PLAY_ALLOWED_HOSTS: scala-blog-rest-api-5d26d52bd1e4.herokuapp.com
PLAY_SECRET: ga87Dd*A7$^SFsrpywMWiyyskeEb9&D$hG!ctWxrp^47HCYI
Push Code to Heroku
With our Procfile
created and our config variables set, we’re ready to push our code to Heroku.
~/project$ git push heroku main
remote: Resolving deltas: 100% (14/14), done.
remote: Updated 95 paths from 4dc4853
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Building on the Heroku-24 stack
remote: -----> Determining which buildpack to use for this app
remote: -----> Play 2.x - Scala app detected
remote: -----> Installing Azul Zulu OpenJDK 17.0.14
remote: -----> Priming Ivy cache... done
remote: -----> Running: sbt compile stage
remote: -----> Collecting dependency information
remote: -----> Dropping ivy cache from the slug
remote: -----> Dropping sbt boot dir from the slug
remote: -----> Dropping sbt cache dir from the slug
remote: -----> Dropping compilation artifacts from the slug
remote: -----> Discovering process types
remote: Procfile declares types -> web
remote:
remote: -----> Compressing...
remote: Done: 128.4M
remote: -----> Launching...
remote: Released v5
remote: https://scala-blog-rest-api-5d26d52bd1e4.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/scala-blog-rest-api.git
* [new branch] main -> main
I love how I only need to create a Procfile
and set some config variables, and then Heroku takes care of the rest of it for me.
Time to Test
All that’s left to do is test my Scala Play app with a few curl requests:
$ curl https://scala-blog-rest-api-5d26d52bd1e4.herokuapp.com/v1/posts \
| jq
[
{
"id": "1",
"link": "/v1/posts/1",
"title": "title 1",
"body": "blog post 1"
},
{
"id": "2",
"link": "/v1/posts/2",
"title": "title 2",
"body": "blog post 2"
},
{
"id": "3",
"link": "/v1/posts/3",
"title": "title 3",
"body": "blog post 3"
},
{
"id": "4",
"link": "/v1/posts/4",
"title": "title 4",
"body": "blog post 4"
},
{
"id": "5",
"link": "/v1/posts/5",
"title": "title 5",
"body": "blog post 5"
}
]
$ curl https://scala-blog-rest-api-5d26d52bd1e4.herokuapp.com/v1/posts/4 \
| jq
{
"id": "4",
"link": "/v1/posts/4",
"title": "title 4",
"body": "blog post 4"
}
$ curl -X POST \
--header "Content-type:application/json" \
--data '{"title":"My blog title","body":"this is my blog post"}' \
https://scala-blog-rest-api-5d26d52bd1e4.herokuapp.com/v1/posts \
| jq
{
"id": "999",
"link": "/v1/posts/999",
"title": "My blog title",
"body": "this is my blog post"
}
The API server works. Deployment was smooth and simple. And I learned a lot about Scala and Play along the way. All in all … it’s been a good day.
Wrapping Up
Jumping into Scala after decades away from Java — and being more used to building web apps with JavaScript — wasn’t as jarring as I expected. Scala’s syntax felt concise and modern, and working with case classes and async controllers reminded me a bit of patterns I already use in Node.js. There’s still a learning curve with the tooling and how things are structured in Play — but overall, it felt approachable, not overwhelming.
Deployment to the cloud had a few gotchas, especially around how Play handles allowed hosts, secrets, and memory usage in production. But once I understood how those pieces worked, getting the app live on Heroku was straightforward. With just a few config changes and a proper startup command, the process was clean and repeatable.
For a first-time Scala build and deployment, I couldn’t have asked for a smoother experience. Are you ready to give it a try?
Happy coding!