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.
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
- Docker multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- GitHub Actions: https://docs.github.com/actions
- Testcontainers: https://java.testcontainers.org/
- Spring Boot container images: https://docs.spring.io/spring-boot/reference/packaging/container-images/index.html
- OAuth Security BCP — RFC 9700: https://www.rfc-editor.org/rfc/rfc9700
That completes the series.