Skip to content
CalliCoder

Deploying Spring Boot to AWS Elastic Beanstalk

Spring Boot 14 min read

The port 5000 convention that decides whether the health check ever passes, why an RDS instance inside the environment is a trap, and what the free tier does and does not cover.

Elastic Beanstalk provisions EC2 instances, a load balancer, auto-scaling and a health check from a single uploaded jar. It is the least AWS-shaped way to run on AWS, and the two things that catch people out are both conventions rather than errors: the port the platform expects, and where the database lives.

Written against Spring Boot 3.2, the Corretto 17 platform and Java 17.

What the free tier actually covers

Elastic Beanstalk itself has no charge. You pay for what it provisions, and the AWS free tier covers some of that for 12 months from account creation:

ResourceFree tierAfter
EC2 t2.micro/t3.micro750 hours/monthhourly
RDS db.t3.micro750 hours/month, 20 GBhourly + storage
Load balancernot coveredhourly + per-GB
Data transfer100 GB/month outper-GB

The load balancer is the line that surprises people — it is billed from the first hour and typically costs more than the instance. A single-instance environment skips it entirely and is the right choice for anything that is not production.

Prepare the application

Beanstalk’s Java platform expects the application on port 5000. The health check hits / on that port, and an application listening on 8080 shows as Severe with no error in the application log, because nothing ever reached it.

server.port=5000

Better, so the same jar runs locally:

server.port=${PORT:8080}

and set PORT=5000 as an environment property in the console or in .ebextensions.

The health check also matters. By default Beanstalk requests /, and a REST API that returns 404 there is reported unhealthy. Either point the check at an endpoint that exists or expose one:

management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=true

then set the health check path to /actuator/health. The Actuator walkthrough covers what that endpoint reports.

Configuration in .ebextensions

# .ebextensions/environment.config
option_settings:
  aws:elasticbeanstalk:application:environment:
    SERVER_PORT: 5000
    SPRING_PROFILES_ACTIVE: prod
  aws:elasticbeanstalk:environment:process:default:
    HealthCheckPath: /actuator/health
  aws:elasticbeanstalk:environment:
    EnvironmentType: SingleInstance

The directory sits at the root of the deployed archive. For a Maven jar that means src/main/resources/.ebextensions will not work — the files must be at the top level of the artifact, which usually means building a zip containing the jar and the directory, or using the ProcFile-style layout the platform also accepts.

Deploying with the EB CLI

pip install awsebcli

eb init -p corretto-17 my-spring-api --region eu-central-1
eb create my-spring-api-env --single
./mvnw -DskipTests clean package
eb deploy
eb open
eb logs

--single creates the environment without a load balancer. Drop it and you get a load-balanced, auto-scaling environment and the hourly charge that comes with it.

eb deploy uploads whatever eb init recorded as the artifact. For a Maven project, point it at the jar:

# .elasticbeanstalk/config.yml
deploy:
  artifact: target/api-0.0.1-SNAPSHOT.jar

Without that line the CLI zips the working directory, and the platform finds no jar to run.

The database: not inside the environment

The console offers to create an RDS instance as part of the environment. Do not accept for anything you care about.

An RDS instance created that way is owned by the environment, so terminating or rebuilding the environment deletes the database with it. Environments get rebuilt — for a platform upgrade, a configuration change that cannot be applied in place, or a blue/green swap — and the data goes with them.

Create the database separately and connect to it:

aws rds create-db-instance \
  --db-instance-identifier my-spring-db \
  --db-instance-class db.t3.micro \
  --engine postgres \
  --allocated-storage 20 \
  --master-username postgres \
  --master-user-password "$DB_PASSWORD" \
  --no-publicly-accessible

Then set the connection details as environment properties:

spring.datasource.url=jdbc:postgresql://${RDS_HOSTNAME}:${RDS_PORT}/${RDS_DB_NAME}
spring.datasource.username=${RDS_USERNAME}
spring.datasource.password=${RDS_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate

Those variable names are the ones Beanstalk injects when the database is attached to the environment, which makes them a convenient convention to keep using when it is not.

The security group is the step that is easy to miss: the database’s group must allow inbound traffic on 5432 from the environment’s instance security group. --no-publicly-accessible plus a group rule scoped to the application is the correct shape; opening the database to 0.0.0.0/0 to make it work is not.

Rolling deployments and health

option_settings:
  aws:elasticbeanstalk:command:
    DeploymentPolicy: Rolling
    BatchSizeType: Percentage
    BatchSize: 50

With more than one instance, a rolling deployment replaces them in batches so the environment stays up. It also means two versions serve at once during the rollout, which matters if a release changes the database schema — the old version must tolerate the new schema, or the deployment needs to be all-at-once with the downtime that implies.

A JVM’s startup time interacts badly with the default health check timing. Beanstalk marks an instance unhealthy before Spring finishes booting on a small instance, replaces it, and repeats. Raising the grace period is usually the fix.

Logs

eb logs                       # tail of the recent logs
eb logs --all --zip           # everything, downloaded

Application output goes to /var/log/web.stdout.log. Streaming to CloudWatch is a checkbox in the environment configuration and worth turning on, because the local logs disappear with the instance that wrote them.

Two log files are worth knowing apart when a deploy fails. eb-engine.log records what the platform did — unpacking the artifact, running the hooks, starting the process — and is where a broken .ebextensions file shows up. web.stdout.log is the application’s own output and holds the stack trace. A deployment that fails with no application log at all almost always failed in the first file.

Memory, and the instance you chose

A t3.micro has 1 GB of RAM shared between the operating system, the platform’s nginx proxy and the JVM. The JVM’s default maximum heap is a quarter of physical memory, so it sizes itself at roughly 256 MB and then spends metaspace, thread stacks and direct buffers on top of that — comfortably enough to be killed by the kernel’s out-of-memory killer under load.

The instance is a real VM, so the fix is an ordinary JVM one. Set the flags in the platform’s JAVA_TOOL_OPTIONS environment property:

JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError

ExitOnOutOfMemoryError is worth having on every managed platform: a JVM that has exhausted its heap usually limps rather than stops, and a process that exits is one the health check will replace, while one that thrashes stays Ok and serves timeouts.

If the environment is load-balanced, the JVM’s own view of available processors is also worth checking — Runtime.availableProcessors() on a burstable instance drives the default thread-pool and fork-join sizing, and two vCPUs is a smaller number than most defaults assume.

When Beanstalk is the right choice

It suits an application that wants a managed EC2 environment without writing the CloudFormation for one, and where the team already has an AWS account. It gives you a real VM, so a JVM agent, a heap dump or a native library are all straightforward.

It is not a container platform. If the deliverable is already an image, ECS or EKS is a shorter path — and the Kubernetes deployment walkthrough covers what that looks like. For a hobby project that needs a URL and no operations, Heroku is less setup.

Frequently asked questions

Why does my application show as Severe with nothing in the log?

It is not listening on port 5000, which is what the Beanstalk Java platform expects. Nothing reaches the application, so it logs nothing.

Is Elastic Beanstalk free?

The service is. The EC2 instance, RDS and load balancer are not, though the first two fall under the 12-month free tier at micro sizes. The load balancer is billed from the first hour, which is why --single matters for a test environment.

Where do .ebextensions files go?

At the root of the deployed artifact, not in src/main/resources. For a jar deployment that means packaging a zip containing both the jar and the directory.

Why is my health check failing on a REST API?

Beanstalk requests / by default and the API returns 404 there. Point HealthCheckPath at /actuator/health and expose the health endpoint.

Should I let Beanstalk create the RDS instance?

No. A database created inside the environment is deleted when the environment is terminated or rebuilt. Create it separately and connect by environment properties.

How do I connect to RDS from the environment?

Set the datasource properties from environment variables, and allow inbound traffic on the database port from the environment’s instance security group. Do not make the database publicly accessible.

Why does eb deploy upload the wrong thing?

Without deploy.artifact in .elasticbeanstalk/config.yml, the CLI zips the working directory. Point it at the built jar.

How do I pick the Java version?

The platform branch, chosen at eb init -p corretto-17. Changing it later is a platform update on the environment rather than an application setting.

What happens to logs when an instance is replaced?

They go with it. Enable CloudWatch log streaming in the environment configuration if you need them to outlive the instance.

Can I do zero-downtime deployments?

With more than one instance and a rolling policy, yes — at the cost of two versions serving simultaneously during the rollout. A schema change has to be compatible with both, or the deployment has to accept downtime.