Docker, CI and v1.0

A non-root container image, keys mounted at runtime, CI against real PostgreSQL, and a clean v1.0.0 release.

  • Part 13
  • advanced
  • about 60 minutes

You will build

a multi-stage Dockerfile, a production-like Compose file, a GitHub Actions pipeline, and the v1.0.0 tag

You will understand

why keys stay out of the image, how to prove production does not seed demo credentials, and what to check before tagging a release

  • one Dockerfile, one workflow

The application is functionally complete. The final job is to make it reproducible outside our laptop.

Our release pipeline should prove:

source
  ↓
clean Maven verify
  ↓
real PostgreSQL integration tests
  ↓
container image builds
  ↓
production-like Compose starts
  ↓
health/discovery/JWKS work

Multi-stage Dockerfile

Use a JDK build stage and a JRE runtime stage:

FROM eclipse-temurin:25-jdk AS build

WORKDIR /workspace

COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN chmod +x mvnw

RUN ./mvnw \
    --batch-mode \
    --no-transfer-progress \
    -DskipTests \
    dependency:go-offline

COPY src src

RUN ./mvnw \
    --batch-mode \
    --no-transfer-progress \
    -DskipTests \
    clean package

RUN JAR_FILE="$(find target -maxdepth 1 -type f -name '*.jar' \
      ! -name 'original-*' ! -name '*sources*' ! -name '*javadoc*' | head -n 1)" \
    && test -n "$JAR_FILE" \
    && cp "$JAR_FILE" /workspace/app.jar

FROM eclipse-temurin:25-jre

WORKDIR /app

RUN groupadd --system app \
    && useradd --system --gid app --home-dir /app \
       --shell /usr/sbin/nologin app

COPY --from=build --chown=app:app \
    /workspace/app.jar /app/app.jar

USER app
EXPOSE 9000
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Why a non-root runtime user? If the application is compromised, the process should not automatically have root privileges inside the container.

.dockerignore

Exclude:

.git
.github
.idea
.vscode
target
.local
.env
.env.*

while allowing .env.example if desired.

Most importantly:

.local/keys/private.pem

must not enter the Docker build context.

Environment example

Publish .env.example, not .env.

Include variables such as:

DB_NAME=oauth2_authorization_server
DB_USERNAME=oauth2
DB_PASSWORD=change-this-password
AUTHORIZATION_SERVER_ISSUER=http://localhost:9000
AUTHORIZATION_SERVER_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
LOGIN_MAX_FAILED_ATTEMPTS=5
LOGIN_LOCK_DURATION=15m
JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75

Production-like Compose

Run PostgreSQL on an internal Docker network and expose only the authorization-server port.

Mount signing keys read-only:

volumes:
  - ./.local/keys:/app/keys:ro

Point Spring configuration at:

file:/app/keys/private.pem
file:/app/keys/public.pem

This is far better than baking keys into the image.

Production profile must not seed development credentials

Use:

SPRING_PROFILES_ACTIVE=prod

Then inspect a fresh production database:

SELECT username FROM users;
SELECT client_id FROM oauth2_registered_client;

There should be no automatic:

user/password
demo-client/demo-secret

because DevelopmentDataInitializer is @Profile("dev").

Container smoke test

Build:

docker build \
  -t spring-boot-oauth2-authorization-server:local \
  .

Run Compose:

docker compose \
  --env-file .env \
  -f compose.production.yaml \
  up --build -d

Verify:

curl -s http://localhost:9000/actuator/health | jq
curl -s \
  http://localhost:9000/.well-known/openid-configuration \
  | jq '.issuer'
curl -s http://localhost:9000/oauth2/jwks | jq

Restart and ensure the JWK kid remains stable.

GitHub Actions CI

A simple CI pipeline is enough for v1. This is the essential shape; the repository’s ci.yml adds step names, a concurrency group that cancels superseded runs, and job timeouts:

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "25"
          cache: maven
      - run: chmod +x mvnw
      - run: ./mvnw --batch-mode --no-transfer-progress clean verify

  docker:
    runs-on: ubuntu-latest
    needs: [test]
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t spring-boot-oauth2-authorization-server:ci .

Because our integration tests use Testcontainers and the GitHub-hosted Linux runner has Docker available, we can test against real PostgreSQL without maintaining a second hand-written CI database configuration.

Fix the default context-load test

One subtle release issue we encountered was the generated contextLoads() test starting without the test profile.

Every specialized integration test passed, but the generic context test attempted to initialize production-like configuration.

The correct fix is to make it consistent:

@SpringBootTest
@ActiveProfiles("test")
class SpringBootOauth2AuthorizationServerApplicationTests {

    @Test
    void contextLoads() {}
}

This ensures the generic context test uses Testcontainers/test JWK behavior too.

Repository release metadata

Add:

README.md
SECURITY.md
LICENSE
docs/architecture.md

In pom.xml, include:

<name>Spring Boot OAuth2 Authorization Server</name>
<description>...</description>
<url>https://github.com/mesubash/spring-boot-oauth2-authorization-server</url>

Apache-2.0 license metadata, SCM information, issue management, and a developer section:

<developers>
    <developer>
        <id>mesubash</id>
        <name>Subash Dhami</name>
        <url>https://github.com/mesubash</url>
        <roles>
            <role>Developer</role>
            <role>Maintainer</role>
        </roles>
    </developer>
</developers>

Avoid publishing a personal email unless you intentionally want it exposed in artifact metadata.

Final checks

Search for leftovers:

git grep -n -E \
  'YOUR_USERNAME|your\.package|TODO|FIXME|HACK|remove later'

Check tracked secrets:

git ls-files | grep -E \
  '(^|/)\.env$|private\.pem|public\.pem|\.p12$|\.jks$'

Expected: no output.

Run the real release verification:

./mvnw clean verify
docker build \
  -t spring-boot-oauth2-authorization-server:1.0.0 \
  .

Release Git workflow

Commit:

git add .
git commit -m "chore: prepare v1.0.0 release"

Push:

git push origin main

After CI is green, create an annotated tag:

git tag -a v1.0.0 \
  -m "Spring Boot OAuth2 Authorization Server v1.0.0"

Push it:

git push origin v1.0.0

Final architecture

flowchart TB
    Client[OAuth/OIDC Client]
    Admin[Administrator]
    AS[Spring Authorization Server]
    DB[(PostgreSQL)]
    Keys[RSA Signing Keys]
    CI[GitHub Actions]
    Docker[Container Image]

    Client --> AS
    Admin -->|Bearer JWT + ROLE_ADMIN| AS
    AS --> DB
    AS --> Keys
    CI -->|mvn verify| AS
    CI -->|docker build| Docker

What we built

By the end of the series, the project supports:

Authorization Code + PKCE
OpenID Connect
JWT access / ID tokens
refresh-token rotation
revocation + introspection
persistent OAuth clients / authorizations / consent
users + roles
custom scope registry
client management
user administration
password lifecycle
login lockout
persistent RSA signing keys
CORS/runtime configuration
audit events
health/readiness
PostgreSQL/Flyway
Testcontainers
Docker
CI

That is a strong v1 foundation.

Where to go after v1

Do not keep expanding the initial release indefinitely. Good v1.1+ extensions include:

  • MFA/passkeys;
  • verified-email workflows;
  • password recovery;
  • external/federated identity providers;
  • dynamic client registration;
  • richer consent UX;
  • KMS/HSM-backed key rotation;
  • organization/multi-tenancy;
  • rate limiting and abuse protection;
  • dedicated admin UI;
  • additional OAuth grants only when a real use case requires them.

References

That completes the series.