Part 9 of 10 of Building Instagram's Authentication Backend. If you're just joining, the repo is here and you can catch the earlier parts on the YouTube playlist.
Eight parts of code, zero runs
By the end of part 8, every layer of this backend existed: DTOs, exceptions, entities, repositories, five services, a security filter chain and a controller. And I had never started the application. Not once. Everything so far was code I'd reasoned through and believed would work.
This part is where that belief gets tested. We containerize the app, put Postgres, Redis and a fake mail server next to it, and bring all four up with one command. It did not work the first time, so this post is the real debugging session, with each bug, its symptom and its fix. If you're containerizing your own Spring Boot app, there's a good chance you'll hit at least one of these.
In this post:
- A multi-stage Dockerfile for Spring Boot, line by line
- A
docker-compose.ymlwith healthchecks and correct startup ordering - The seven bugs, in a table, with fixes
- Why
localhostinside a container isn't your laptop (the one to remember) - What a successful first boot looks like, and how to check it
- What I'd still change before calling this production-ready
The Dockerfile: two stages, one reason
Here's the Dockerfile exactly as I first wrote it. It contains one of the seven bugs; see if you can spot it before I point it out.
Dockerfile
# Build Stage
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apk add --no-cache maven && mvn clean package -DskipTests
# Runtime Stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSuppot", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "app.jar"]
There are two FROM lines, and that's the whole idea of a multi-stage build.
Stage one uses the full JDK image, because compiling needs a compiler. It copies the pom.xml and the source, installs Maven, and builds the jar. This stage ends up large: a JDK, Maven and every dependency. That's fine, because almost none of it ships.
Stage two starts from a JRE-only image: no compiler, no Maven, nothing needed to build the app, only what's needed to run it. The line that makes it work is COPY --from=build /app/target/*.jar app.jar, which reaches back into stage one and takes only the finished jar. The result is a smaller image, a smaller attack surface and a faster pull.
Before the jar is copied, the Dockerfile creates a spring user and switches to it with USER spring:spring. Without that, the container runs as root, and a compromised app would be a root process. It's three lines for a real security habit.
-XX:MaxRAMPercentage=75.0 tells the JVM to size its heap from the container's memory limit rather than from the host's RAM. -Djava.security.egd=file:/dev/./urandom is an older flag that speeds up SecureRandom seeding, which our OTP generator uses; on a current JDK it's mostly harmless legacy.
Bug 1: a typo that builds fine and dies on start
Now read the flag on the first ENTRYPOINT line again: -XX:+UseContainerSuppot. It's missing an r. This is a nasty bug because docker build succeeds. Nothing parses the JVM flags until the container starts, and then the JVM refuses to run:
what the container prints
Unrecognized VM option 'UseContainerSuppot'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
A working image that refuses to run. The fix is one letter:
Dockerfile (diff)
- "-XX:+UseContainerSuppot", \
+ "-XX:+UseContainerSupport", \
Worth knowing: UseContainerSupport has been on by default since JDK 10, so you could delete the flag entirely. I keep it because it documents that MaxRAMPercentage relies on it.
docker-compose.yml: describing a whole system
One Dockerfile builds one image. But this app needs Postgres, Redis and a mail server to function, and docker-compose.yml is how you describe all of that together. Two parts of it are worth slowing down for.
First, startup ordering. A plain depends_on only waits for a container to start, and Postgres can report "started" long before it accepts connections. The condition: service_healthy form waits for the healthcheck to pass:
docker-compose.yml, app service
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
Those healthchecks live on the dependencies themselves:
docker-compose.yml, postgres service
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
docker-compose.yml, redis service
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
pg_isready and redis-cli ping are the same checks an operator would run by hand. Compose runs them every ten seconds, and the app container doesn't start until both pass.
Second, persistence and networking. Postgres and Redis each mount a named volume, so you can lose the container and keep the data. All four services join one network, auth-net. MailHog is a fake SMTP server (port 1025) with a web UI (port 8025), so the OTP emails our app sends have somewhere visible to land without a real mail provider:
docker-compose.yml, mailhog service
mailhog:
image: mailhog/mailhog
container_name: instagram-auth-mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # web UI
networks:
- auth-net
That looks complete. Time to find out.
The first docker compose up: hitting the wall
docker compose up --build
It doesn't come up clean. Rather than fix things one at a time, I went through both files and the config properly and wrote down everything that was wrong:
| # | Where | What was wrong | Fix |
|---|---|---|---|
| 1 | Dockerfile | UseContainerSuppot typo. Builds fine, JVM refuses to start. | UseContainerSupport |
| 2 | docker-compose.yml | redis and mailhog were indented one level too deep, so YAML read them as properties of postgres instead of services. | Move both back to the same level as app and postgres |
| 3 | docker-compose.yml | SORING_PROFILES_ACTIVE. Spring never sees a misspelled variable. | SPRING_PROFILES_ACTIVE |
| 4 | docker-compose.yml | internal: 10s in the Postgres healthcheck. | interval: 10s |
| 5 | docker-compose.yml | internal: 10s in the Redis healthcheck. | interval: 10s |
| 6 | application.yml | Datasource URL hardcoded to localhost:5432. | ${DB_HOST:localhost} plus DB_HOST: postgres in compose |
| 7 | application.yml | Mail host hardcoded to localhost. | ${MAIL_HOST:localhost} plus MAIL_HOST: mailhog in compose |
Bugs 2 to 5 are the kind you find by reading closely. YAML is whitespace-sensitive, so bug 2 is a typo in structure, not in a word, and it's invisible unless you look. Bugs 6 and 7 are the interesting ones.
application-dev.yml, so the dev profile has no file to load. I'd rather tell you that than imply the typo was breaking something.Bugs 6 and 7: localhost doesn't mean what you think
Here's the datasource config from application.yml as it stood:
application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/instagram_auth
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
On your laptop, localhost means "this machine," and Postgres is running on it, so this works. Inside a container, localhost means this container and nothing else. Postgres isn't running inside the app's container. It's a separate container, named postgres, on the same Docker network.
your laptop Docker network: auth-net
┌────────────┐ ┌──────────────────────────────────────────┐
│ localhost │ │ ┌───────────┐ ┌────────────────┐ │
│ = laptop │──────► │ app │──────►│ postgres │ │
└────────────┘:8080│ │ localhost │ │ :5432 │ │
│ │ = itself │ └────────────────┘ │
│ │ │ │
│ │ │ ┌────────────────┐ │
│ │ │──────►│ redis │ │
│ │ │ │ :6379 │ │
│ └───────────┘ └────────────────┘ │
└──────────────────────────────────────────┘
inside Compose, containers reach each other by SERVICE NAME
So the app tries to connect to port 5432 on itself, finds nothing, and the Postgres driver tells you so:
the symptom
Connection to localhost:5432 refused. Check that the hostname and port are
correct and that the postmaster is accepting TCP/IP connections.
In Docker Compose, containers reach each other by service name. postgres isn't just a label in your YAML. It's the DNS name the app has to connect to. The fix has two halves, and it uses the same pattern from part 2: an environment variable with a default. In application.yml:
application.yml (fixed)
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:5432/instagram_auth
mail:
host: ${MAIL_HOST:localhost}
Run the app locally without Docker and it still defaults to localhost, so nothing changes for development. But now the host can be overridden, and compose does that:
docker-compose.yml, app environment (fixed)
environment:
SPRING_PROFILES_ACTIVE: dev
DB_HOST: postgres
DB_USERNAME: postgres
DB_PASSWORD: postgres
REDIS_HOST: redis
REDIS_PORT: 6379
MAIL_HOST: mailhog
Notice that Redis was already in good shape. Back in part 2 I'd written host: ${REDIS_HOST:localhost} in application.yml, so REDIS_HOST: redis in compose was all it needed. The database and mail hosts were the two I'd forgotten to parameterize.
If you remember one thing from this part: inside Docker, another container's address is its service name, never localhost.
First boot
With the fixes applied, one more time:
docker compose up --build
Watch the logs in order. Postgres reports healthy, then Redis. The app container starts. Then Flyway runs V1__create_auth_tables.sql, the migration from part 5, against a real database for the first time. It creates the two tables and the updated_at trigger:
V1__create_auth_tables.sql (excerpt)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(30) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255),
full_name VARCHAR(100),
-- ...
);
CREATE TABLE refresh_tokens ( ... );
Because ddl-auto is set to validate, Hibernate then checks that our entities match those tables, and fails the boot if they don't. After that, Tomcat starts on port 8080 and the application reports that it started. Eight parts of code, finally running.
You can confirm the migration ran without any client tool:
docker compose ps
docker exec -it instagram-auth-postgres psql -U postgres -d instagram_auth -c "\dt"
docker compose ps should show all four containers up. Only Postgres and Redis show (healthy), because they're the only ones with healthchecks; the app and MailHog just show Up. The \dt output should list users, refresh_tokens and Flyway's own flyway_schema_history table.
I stopped the video here on purpose. This part is about the app running. Actually using it deserves its own part.
What I'd still change
Booting isn't the same as production-ready. Re-reading these files for this post, here's what I'd tighten:
- The
JWT_SECRETvariable in compose does nothing.application.ymlhas the signing secret hardcoded underapp.jwt.secret, so the environment variable is never read. It should be${JWT_SECRET:...}, the same pattern as everything else, and the real secret should never be committed. - Secrets are inline in compose. Database credentials and the JWT secret sit in the YAML. Move them to an
env_file: .envthat's in.gitignore. - Dependencies re-download on every code change. The Dockerfile copies
pom.xmlandsrctogether, so any source edit invalidates the Maven layer. Copy the pom first and runmvn dependency:go-offlinebefore copyingsrc, and rebuilds get much faster. - No
.dockerignore. The build context includes.gitand any localtargetfolder. - The app has no healthcheck of its own, and
mailhog/mailhoghas no pinned tag. - Postgres and Redis ports are published to the host. That's convenient for development, but close them in any shared environment.
What's next
The app runs: in containers, talking to a real Postgres and Redis, started with one command. But it's sitting there healthy and idle. In the final part, we use it: Swagger UI, every endpoint, real verification emails in MailHog, and then deliberately breaking it with an account lockout, the rate limiter and a stolen refresh token.
Full source is in the GitHub repo. If you've hit the localhost-in-Docker trap before, tell me in the comments. I'd like to know I'm not the only one.
Comments
Post a Comment