The chat app works. It remembers conversations. It stores history in PostgreSQL.
And it runs on my laptop.
That's not a chat app. That's a script. The moment I close the terminal, it's gone. Nobody else can use it.
This article is about fixing that. We're taking the app and deploying it to the cloud so it runs 24/7 at a real URL.
Where to deploy — a quick tour
There are several platforms that make deploying a Spring Boot app straightforward. Here's the honest overview:
Railway — fast setup, generous free tier, great developer experience. The free tier has a monthly usage limit that can catch you off guard if the app gets traffic.
Fly.io — powerful, runs real VMs in regions close to your users, excellent for performance-sensitive apps. The free tier is there but the setup is more involved — you configure regions, vm sizes, and scaling yourself.
Koyeb — solid option, deploys from Docker or GitHub, decent free tier. Less documentation than the others.
Render — this is what I used. Free tier, no credit card required for basic deployment, deploys directly from a GitHub repo and rebuilds on every push. The free tier does spin down after 15 minutes of inactivity, but for learning and demos it's exactly what you need.
We're going with Render.
What you need before deploying
Three things need to be true before deployment works:
- Your code is in a GitHub repo
- Your secrets are in environment variables, not in the code
- Your app is packaged as a Docker image
You probably have 1 and 2 already. Let's talk about 3.
Why Docker?
When you run the app locally, Java is installed on your machine. Maven is installed. The right version of everything is there.
Render's servers don't have your setup. They don't know you're using Java 17. They don't know your Maven version. They don't know your dependencies.
Docker solves this by packaging your app and everything it needs into one self-contained image. The image runs the same way everywhere — your laptop, Render, AWS, anywhere.
Think of it like a shipping container. Before shipping containers, every port had to know how to handle every type of cargo. After containers, you just move the box — the contents are someone else's problem.
A Dockerfile is the recipe for building that box.
The Dockerfile
Create a file called Dockerfile in the root of your project (same level as pom.xml):
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/gemini-chat-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
This is a multi-stage build. Two stages, one file.
Stage 1 — build the app:
FROM maven:3.9-eclipse-temurin-17 AS build starts from a base image that already has Maven and Java 17 installed. You name this stage build.
COPY pom.xml . then RUN mvn dependency:go-offline — this is a Docker trick. By copying just pom.xml first and downloading dependencies before copying source code, Docker can cache that layer. If you only change Java files later, Docker skips the dependency download step entirely. Builds get much faster.
COPY src ./src then RUN mvn package -DskipTests — now copy the source and compile. -DskipTests is intentional here; you'd run tests in CI separately.
Stage 2 — run the app:
FROM eclipse-temurin:17-jre — this is a different base image. Just the JRE (Java Runtime Environment), not the full JDK with Maven. Much smaller — a JDK image can be 500MB+, a JRE is ~200MB.
COPY --from=build /app/target/gemini-chat-0.0.1-SNAPSHOT.jar app.jar — copy just the compiled jar from stage 1. The Maven installation, all the source code, the intermediate build files — none of that comes along.
EXPOSE 8080 — tells Docker (and Render) what port the app listens on.
ENTRYPOINT ["java", "-jar", "app.jar"] — the command that runs when the container starts.
The final image contains only the JRE and your jar. Everything else stays behind in stage 1 and is discarded.
Where does the Docker image go?
This is something I wasn't clear on at first.
You never push a Docker image anywhere. You don't need Docker Hub. You don't need Docker installed on your machine at all.
Here's what actually happens when you deploy to Render:
- You push your source code to GitHub (including the
Dockerfile) - Render pulls that code from GitHub
- Render runs your
Dockerfileon their own build servers — Maven downloads dependencies, compiles your Java code, packages the jar - The resulting Docker image is stored in Render's internal container registry
- Render starts a container from that image and gives you a URL
The image lives on Render's infrastructure. Maven is only involved inside stage 1 of the build — it's just the tool that compiles the Java code. Once the image is built, Maven is gone.
The only reason to run Docker locally is if you want to test the image before pushing:
docker build -t gemini-chat .
docker run -p 8080:8080 -e GEMINI_API_KEY=your_key gemini-chat
But for deploying — GitHub to Render is all you need.
Check your environment variables
Before pushing, verify your application.properties uses environment variables, not hardcoded values:
# Gemini
spring.ai.google.gemini.api-key=${GEMINI_API_KEY}
spring.ai.google.gemini.chat.options.model=gemini-2.5-flash
# Database
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
spring.sql.init.schema-locations=classpath:chat-memory-schema.sql
spring.sql.init.mode=always
Every secret is read from the environment at runtime. If someone opens your GitHub repo, they see variable names, not actual values.
Also check your .gitignore — this should already be there, but verify:
# Never commit this
.env
*.env
application-local.properties
Deploy to Render
Step 1 — Push your code to GitHub
Your project needs to be in a GitHub repo. If it's not already:
git init
git add .
git commit -m "initial commit"
git remote add origin https://github.com/yourusername/gemini-chat.git
git push -u origin main
Make absolutely sure your API keys and database credentials are not in any committed file.
Step 2 — Create a Render account
Go to render.com and sign up. You can use GitHub to sign in — that's the easiest option since Render will need access to your repos anyway.
Step 3 — Create a new Web Service
In the Render dashboard:
- Click New → Web Service
- Connect your GitHub account and select your repository
- Render will detect the
Dockerfileautomatically
Set the basics:
- Name: gemini-chat (or whatever you like)
- Region: Pick the closest one to you
- Branch: main
- Instance type: Free
Step 4 — Add environment variables
In the service settings, scroll to Environment Variables and add:
| Key | Value |
|---|---|
GEMINI_API_KEY |
your Gemini API key |
DATABASE_URL |
your Neon connection string |
DATABASE_USERNAME |
your Neon username |
DATABASE_PASSWORD |
your Neon password |
This is where secrets live on Render — not in your code, not in your repo. Render injects these as environment variables when the container starts.
Step 5 — Deploy
Click Create Web Service. Render will:
- Pull your code from GitHub
- Build the Docker image (this runs your Dockerfile — Maven downloads dependencies, compiles the app, packages the jar)
- Start the container
- Give you a URL like
https://gemini-chat-xxxx.onrender.com
The first build takes a few minutes. After that, every push to main triggers a new build automatically.
Test it
Once the service is live, send a request to your Render URL:
curl -X POST https://gemini-chat-xxxx.onrender.com/api/chat-ai/session
You should get back a conversation ID. Then:
curl -X POST https://gemini-chat-xxxx.onrender.com/api/chat-ai/chat \
-H "Content-Type: application/json" \
-d '{"conversationId":"your-id-here","message":"Hello, are you running in the cloud?"}'
If you get a response — it's live. Running on Render's servers, hitting Gemini's API, storing history in Neon PostgreSQL. Not on your laptop anymore.
The free tier caveat
Render's free tier spins the service down after 15 minutes of no traffic. The first request after a sleep wakes it up, which takes about 30-60 seconds. For production you'd pay for an always-on instance, but for learning and demos the free tier is fine.
If you want to keep it warm, you can set up a simple ping from a service like UptimeRobot — free, hits your URL every 5 minutes, keeps the service awake.
What just happened
Let's step back and look at what you've built:
- A Spring Boot REST API that calls Gemini
- Conversation history that persists in PostgreSQL
- Deployed to the cloud with Docker
- Running at a real URL, 24/7
- All secrets safely in environment variables, never in code
That's a real AI backend. Not a tutorial project — a deployed service.
What's next
The app is live. Next: making the responses stream in real time — instead of waiting for the full reply, you see tokens appear as the model generates them. Same way ChatGPT works.
Deployed your first AI app? Drop the URL in the comments.
Sham Prakash K — Backend Engineer, 4+ years in Java, Spring Boot, and distributed systems. Building AI backend infrastructure. Writing about what I actually learned, mistakes included.
Top comments (0)