Deployment
This chapter covers what an application does instead of calling app.start(port): deploying to an external servlet container, precompiling templates, building a container image with Jib, and writing a Dockerfile where a platform demands one.
An external servlet container
Deploy to an external servlet container by skipping start and mapping AppServlet yourself:
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
ServletHolder holder = new ServletHolder(new AppServlet(app));
holder.setInitOrder(0); // initialize while the context starts
context.addServlet(holder, "/*");
AppServlet takes the routes and settings of its App when the container initializes it, and registration on that App is closed until the container destroys it.
A route or setting registered after that point throws IllegalStateException, because the servlet would never see it.
A container initializes a servlet on its first request unless told otherwise, so map it to load on startup: setInitOrder(0) on Jetty, <load-on-startup>0</load-on-startup> in a web.xml, or the container’s equivalent.
Registration then closes when the application deploys rather than when it is first called.
A subclass that overrides init(ServletConfig) or destroy() calls the superclass method, as the servlet API asks of every servlet.
AppServlet is a standard servlet, so it runs on any container, not just Jetty.
An embedded Tomcat that app.start(port) runs itself is a separate module, spider-silk-tomcat: see Tomcat, and Undertow for the third embedded server.
Exclude the bundled Jetty when deploying to a container you did not start:
implementation('net.benelog.spidersilk:spider-silk-core') {
exclude group: 'org.eclipse.jetty.ee10'
}
Precompiled templates
jte compiles templates at runtime by default, which requires a JDK on the deployment machine. A production build precompiles instead. The jte Gradle plugin turns each template into a Java class at build time, so the jar renders compiled classes, and a template that does not compile fails the build rather than the request. The plugin configuration and the matching constructor are in Templates, alongside the development-mode counterpart. A native image makes this mode a requirement rather than an optimization, since a native binary carries no compiler.
A container image, without Docker
Jib builds a container image from the Gradle model alone, so the build machine needs no Docker daemon.
gradle :example-flashcard:jibBuildTar writes build/jib-image.tar, ready for any Docker host to load.
The example applies Jib through the spider-silk plugin, which already sets the JRE 21 base and port 8080, so the build states only the image name:
plugins {
id 'net.benelog.spidersilk'
}
jib {
to {
image = 'ghcr.io/benelog/spider-silk-flashcard'
}
}
Without the plugin, the same image takes Jib’s own plugin (com.google.cloud.tools.jib) and adds from.image = 'eclipse-temurin:25-jre' and container.ports = ['8080'] to the block above.
Jib splits the image into layers that follow the structure of the build, and that split makes rebuilds and pushes cheap.
Four tiers each become a layer: external dependencies (the Jetty, Spring JDBC, and H2 jars), project dependencies (the spider-silk-core and spider-silk-openapi jars, on their own so a framework edit leaves the larger external layer untouched), resources, and classes.
An edit to the application code rewrites only the small classes layer on top.
Jib also pins every timestamp to the epoch, so an unchanged layer reproduces bit-for-bit with the same digest, and a registry push skips it instead of re-uploading.
The spider-silk jars count as project dependencies only in this repository, where the example and the framework build together. An application that pulls spider-silk from a repository sees it as one more external jar, cached in the dependencies layer with Jetty and the rest, and rewritten only on a version bump.
The entrypoint comes from the application block’s mainClass.
A JRE base suffices because the templates ship as the precompiled classes from the section above, and nothing in the image compiles at runtime.
The plugin also works around one Jib check.
Jib checks the app’s targetCompatibility against the base image’s Java version, and a build that targets 25 through options.release alone leaves targetCompatibility at the toolchain’s version, so the check refuses the JRE 25 base under a newer toolchain.
The plugin copies the release into targetCompatibility, which by hand is java { targetCompatibility = JavaVersion.VERSION_25 }.
gradle :example-flashcard:jibBuildTar
# then, on a machine that runs containers:
docker load < example-flashcard/build/jib-image.tar
docker run -p 8080:8080 ghcr.io/benelog/spider-silk-flashcard
The tar is only the daemonless route: gradle jib pushes the same image straight to the registry given credentials, and gradle jibDockerBuild loads it into a local daemon where one exists.
H2 keeps its database file under the process home, /root/db in this image, so the data lives and dies with the container unless a volume is mounted there.
The same build containerizes the GraalVM binary instead of the jar when run with -Pnative.
That flag swaps the base image and the layers, and the native image chapter shows what changes.
A Dockerfile, where a platform demands one
The example carries a Dockerfile for the platforms that build only from one: example-flashcard/Dockerfile, a multi-stage build run from the repository root so the context holds every module of the multi-module build.
docker build -f example-flashcard/Dockerfile -t spider-silk-flashcard .
docker run -p 8080:8080 spider-silk-flashcard
The file reproduces by hand the layering Jib does for free, so every instruction is ordered for the layer cache.
The build stage first copies only the Gradle scripts, of every module since settings.gradle names them all, and downloads the dependencies before any source enters the image.
A source edit therefore re-runs compilation but never the download.
That download is resolveDependencies, a task the spider-silk plugin registers to fetch the external runtime jars and nothing else.
The stock dependencies report is not a substitute: it stops at metadata and caches no application jar at all.
The runtime stage then copies Jib’s four tiers as four COPY instructions, least volatile first: the external jars, the spider-silk jars, the resources, and the application’s classes as loose .class files rather than a jar.
Skipping the jar removes the one thing that could change without the code changing.
An archive repackaged with fresh timestamps reads as new content, while an unchanged .class file keeps the same bytes, so a comment-only edit that compiles to identical bytecode leaves even the classes layer cached.
(Gradle builds reproducible jars by default these days, which blunts that problem.
Maven’s jar plugin does not, unless project.build.outputTimestamp says so, which makes loose classes matter more in a Maven build.)
In an application of your own, spider-silk’s jars belong to the external tier, since they change on a version bump and not on your edits.
The core tier then disappears: it exists here only because the framework builds alongside the example.
COPY caches by content, so a code change re-runs the whole build stage yet rewrites only the small classes layer at the end.
Docker’s cache differs from Jib’s in one respect.
Docker caches the instructions as a chain, so an edit rewrites its own tier and the tiers after it: a template or static-file edit rewrites resources and classes, both small.
Jib’s layers are independent of one another.
The entrypoint states what the jar’s manifest no longer can: the classpath, in Jib’s order, and the main class.
The .dockerignore at the root is part of this setup: it keeps build/ directories out of the context, since they would otherwise invalidate the source layers on every build.
The image matches the Jib one in its port, in its base, and in the H2 caveat about the process home.
The Dockerfile builds on eclipse-temurin:25-jdk and runs on eclipse-temurin:25-jre, the JRE Jib runs on.