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:
| Resource | Free tier | After |
|---|---|---|
EC2 t2.micro/t3.micro | 750 hours/month | hourly |
RDS db.t3.micro | 750 hours/month, 20 GB | hourly + storage |
| Load balancer | not covered | hourly + per-GB |
| Data transfer | 100 GB/month out | per-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.