Shipping Hilla apps to production. Part 1: Production Build

René Wilby | Sep 16, 2024 min read

Article series

This is the first of four articles describing different aspects of shipping Hilla apps to production:

  • Part 1: Production Build
  • Part 2: Docker Images
  • Part 3: CI/CD
  • Part 4: Serverless Deployment

Production Build

A Hilla app can be packaged differently and the subsequent execution environment can also vary. However, the common starting point in all cases is the Production Build. Each Hilla app can be built as a production-ready artefact with the help of a corresponding configuration profile. In conjunction with Maven, this task is performed by the Vaadin-Maven-Plugin. In a production profile, this plugin executes the build-frontend goal:

<profile>
    <id>production</id>
    <build>
        <plugins>
            <plugin>
                <groupId>com.vaadin</groupId>
                <artifactId>vaadin-maven-plugin</artifactId>
                <version>${vaadin.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>build-frontend</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</profile>

Hilla apps created, for example, via https://start.vaadin.com or with the help of the Vaadin CLI via npm init vaadin already contain all the configurations required to use this profile. For Hilla apps that do not yet contain the required configuration, there is a manual that describes how this can be done.

This profile can be executed via the following command line call to create a production build:

mvn clean package -Pproduction

During the creation of the production-ready artefact, the following tasks, among others, are executed:

  • The goal prepare-frontend will be executed. In this step, the necessary prerequisites are established for the subsequent bundling of the frontend using Vite.
  • The application will be compiled and the relevant TypeScript code for the frontend will be generated.
  • The build-frontend goal will be executed. Among other things, all required dependencies will be installed, and the frontend bundle will be created using Vite. As part of the bundling, all ES6 JavaScript files are transpiled into the ES5 JavaScript format, the code gets minimized and obfuscated and files are combined so that they can be loaded faster later in the browser.

To ensure that the artefact created is as compact as possible, certain dependencies that are only relevant during development should not be included in the production build. The dependency com.vaadin:vaadin-dev-server can be excluded for this purpose, for example, as follows:

<profiles>
    <profile>
        <id>production</id>
        <dependencies>
            <!-- exclude development dependencies from production -->
            <dependency>
                <groupId>com.vaadin</groupId>
                <artifactId>vaadin-core</artifactId>
                <exclusions>
                    <exclusion>
                        <groupId>com.vaadin</groupId>
                        <artifactId>vaadin-dev</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
        </dependencies>
        <!-- production build configuration from above -->
    </profile>
</profiles>

The frontend bundle in the production-ready artefact consists of an index.html file and associated JavaScript and CSS files that are loaded by the browser.

META-INF/VAADIN/webapp/
- index.html
- VAADIN/build/
  - indexhtml.js
  - indexhtml.css
  - FlowBootstrap.js
  - FlowClient.js
  - generated-flow-imports.js

If you want to get an overview of the exact content and size of the frontend bundle, you can look at the file target/classes/META-INF/VAADIN/config/bundle-size.html in the production build:

Bundle-Size

In addition to the frontend bundle, the production-ready artefact also contains the backend code consisting of the compiled Spring Boot project with the application-specific code of the Hilla app and all necessary dependencies.

Production build as Fat JAR with integrated Servlet Container

Per default, the production build results in an executable JAR file. As a Hilla app is based on Spring Boot, the JAR file created also contains an integrated Servlet Container in addition to the frontend bundle, the compiled backend code and all necessary dependencies. The integrated Servlet Container serves the Hilla app as soon as the JAR file is executed. This means that only a Java runtime environment and the following call are required to deploy a Hilla app in the form of this JAR file:

java -jar target/my-app.jar

The JAR file is packaged using the Spring-Boot-Maven-Plugin. The plugin takes a regular JAR file, which only contains the application code, and creates a Fat JAR (or Uber JAR) that contains all the necessary dependencies, additional configurations and the integrated Servlet Container. The Fat JAR of a Hilla app that was created as a production build has the following structure and content:

BOOT-INF/
- classes/
  - application.properties
  - banner.txt
  - com/application/example/
    - Application.class
    - services/
  - hilla-openapi.json
- classpath.idx
- layers.idx
- lib/
META-INF/
- MANIFEST.MF
- maven/
- resources/
- services/
- VAADIN/
  - config/
  - file-routes.json
  - webapp/
org/springframework/boot/loader
  • The BOOT-INF directory contains the compiled backend code of the Hilla app (classes) and all necessary dependencies (lib).
  • The META-INF directory contains the MANIFEST.MF file, additional metadata, static resources and the frontend bundle of the Hilla app.
  • The directory org/springframework/boot/loader contains Spring Boot-specific classes and configurations that enable the execution of the Fat JAR.

The MANIFEST.MF file has a special function in a JAR file. In addition to some metadata, it particularly contains the information that is relevant for making the JAR file executable. For the production build of a Hilla app, the MANIFEST.MF file can have the following content:

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 21
Implementation-Title: hilla-production-build-test
Implementation-Version: 1.0-SNAPSHOT
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.example.application.Application
Spring-Boot-Version: 3.3.2
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx

Among other things, the MANIFEST.MF file defines the Main-Class and the Start-Class. In this case, the Main-Class is org.springframework.boot.loader.launch.JarLauncher. This class is located in the directory org.springframework/boot/loader/launch and is provided by Spring Boot to enable the application to be launched when the Fat JAR is executed. The Start-Class in this example is com.example.application.Application. This class is located in the directory BOOT-INF/classes/com/application/example and belongs to the Hilla app. The entries Spring-Boot-Classes and Spring-Boot-Lib refer to the compiled backend code of the Hilla application and all necessary dependencies.

The integrated Servlet Container also plays a special part in the fat JAR. Per default, this is an integrated Apache Tomcat. The fact that an executable Fat JAR also contains an integrated Servlet Container in addition to the application simplifies the deployment of a Hilla app significantly. On the one hand, there is no need to install and configure a separate Servlet Container. On the other hand, it ensures that the Hilla app is always executed in an isolated and constant environment during local development as well as during execution in CI/CD systems and in production.

Further information on the special characteristics of a Spring Boot Fat JAR can be found in this blog post published on Stackademic.

Production build as WAR file

The production build of a Hilla app can also result in a WAR file. The following adjustments are required to achieve this:

  1. The class Application.java must be modified. It has to extend the class SpringBootServletInitializer:
package com.example.application;

import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.theme.Theme;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

@SpringBootApplication
@Theme(value = "hilla-production-build-test")
public class Application extends SpringBootServletInitializer implements AppShellConfigurator {

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(Application.class);
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  1. The packaging format must be changed from jar to war in the pom.xml file:
<!-- ... -->
    <groupId>com.example.application</groupId>
    <artifactId>hilla-production-build</artifactId>
    <name>hilla-production-build</name>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>
<!-- ... -->
  1. It has to be ensured that the created WAR file no longer contains an embedded Servlet Container. To do this, the corresponding dependency is added with the scope provided in the pom.xml file:
<!-- ... -->
    <dependencies>
        <!-- ... -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
            <scope>provided</scope>
        </dependency>
        <!-- ... -->
    </dependencies>
<!-- ... -->

Following these adjustments, the production build of the Hilla app can be generated again:

mvn clean package -Pproduction

The generated WAR file has the following content and structure:

WEB-INF/
- classes/
  - application.properties
  - banner.txt
  - com/application/example/
    - Application.class
    - services/
  - hilla-openapi.json
  - META-INF/
    - resources/
    - VAADIN/
- classpath.idx
- layers.idx
- lib/
- lib-provided
org/
META-INF/
- MANIFEST.MF
- maven/
- services/
  • The directory WEB-INF contains the compiled backend code of the Hilla app (classes), static resources (META-INF/resources), the frontend bundle of the Hilla app (META-INF/VAADIN) and all necessary dependencies (lib).
  • The directory org/springframework/boot/loader contains Spring Boot-specific classes and configurations that enable the WAR file to be executed.
  • The META-INF directory contains the MANIFEST.MF file and additional metadata.

It is recognizable that the WAR file has the same content as the JAR file described above. A Servlet Container is also present in the WEB-INF/lib-provided directory. This is quite surprising, as the Servlet Container was configured as provided in the pom.xml file. The following explanation can be found in the Spring Boot documentation:

If you use the Spring Boot Build Tool Plugins, marking the embedded servlet container dependency as provided produces an executable war file with the provided dependencies packaged in a lib-provided directory. This means that, in addition to being deployable to a servlet container, you can also run your application by using java -jar on the command line.

The generated WAR file can therefore also be executed directly like a JAR file. However, a WAR file is usually deployed via an independent Servlet Container, such as Tomcat, Jetty or Undertow.

Production build as Native Image

Another option for creating a production-ready artefact is to generate a production build in the form of a Native Image. This requires a JDK with GraalVM support. A Hilla app built as a Native Image is a stand-alone executable file that can be run without a Java runtime environment. This simplifies the deployment even further. Compared to a Hilla app built and executed as a JAR file, a Hilla app built and executed as a Native Image is usually characterized by a faster startup time and lower memory usage. Supported by GraalVM, the production build of a Hilla app can be created as a Native Image as follows:

mvn clean package -Pproduction -Pnative native:compile

The Native Image created contains all the necessary classes, dependencies and the frontend bundle. The Native Image also contains an integrated Servlet Container that serves the Hilla app as soon as the native image is executed.

The generated production build can be executed as a Native Image as follows:

./target/my-app

Native Image in detail

The creation of a production build in the form of a Native Image takes place ahead of time and differs in some aspects from the creation of an executable JAR or WAR file:

  • When the Native Image is created, a static code analysis is performed starting from the Main-Class. Code that cannot be reached during this analysis is not part of the Native Image.
  • Dynamic components in the code, such as reflections, serialization or dynamic proxies, must be marked accordingly so that they can be taken into account when creating a Native Image.
  • The classpath of the application is defined when it is created and cannot be changed later.
  • All classes are loaded when the Native Image is started. It is not possible to subsequently load additional classes.

The list shows that frameworks such as Hilla and Spring Boot had to invest a lot of work in order to support Native Images. Applications based on Spring Boot are actually characterized by the fact that they are very dynamic and that their (auto)configuration takes place at runtime. Spring Boot achieves support for Native Images through a process called Spring Ahead-of-Time Processing. This process is run at build time of an application.

An important part of this process is the transformation of parts of the source code, with the aim that the source code can be better analysed by the GraalVM compiler. This applies, for example, to classes with the @Configuration or @Bean annotation. Spring would normally analyse these classes at runtime and generate the required bean definitions at runtime. For a Native Image, however, this is already done at build time.

Another part of the process is the creation of so-called Hint files. These JSON files contain additional information that the GraalVM compiler can use to better understand the code as part of the static analysis. This type of information is required, for example, when Spring uses the Java Reflection API to call private methods of a class. The GraalVM compiler can recognize that the private method is actually being used by looking into a corresponding Hint file. Without this mechanism, the GraalVM compiler would not be able to recognize the use during the static analysis and the method would therefore not be part of the native image.

The Hilla framework also has dynamic components that must be made known to the GraalVM compiler too. Hilla uses so-called Custom Hints for this purpose. In the HillaHintsRegistrar, among other things, files and classes are made known that are responsible for the dynamic generation of the menu in a Hilla app.

Summary

With the help of the production build, Hilla apps can be prepared for deployment in production in an optimal way. Packaging as an executable JAR file or as a Native Image with an integrated Servlet Container creates a very good basis for simplified deployment.

Part 2 of the article series will cover the creation of suitable and efficient Docker images, which are required if the production-ready Hilla app should be delivered in container environments.