# Application Build and Deployment This document details the general steps used to build and deploy a typical full-stack Hoist application. We consider the Grails-based back-end and React-based front-end to be two sides of the same application. We build and deploy them together, and so the below includes info on building the Grails / Gradle based server. That is technically the domain of [Hoist Core](https://github.com/xh/hoist-core) but is detailed here to provide a consolidated look at the build process. For CI/CD of the hoist-react library itself (linting, npm publishing), see [Build & Publish](./build-and-publish.md). At a high level, the application build process: - Builds the Grails back-end via Gradle, producing a WAR file. - Builds the JS front-end via Rsbuild, producing a set of production-ready client assets. - Copies both outputs into a pair of Docker containers and publishes those containers as the end-product of the build. - Deploys the new images, immediately or in a later step. Hoist does not mandate the use of any particular CI system for application builds. The examples in this section use generic shell commands that can be adapted to any CI platform (GitHub Actions, GitLab CI, Jenkins, etc.). Hoist also does not require the use of Docker or containerization in general, nor does it rely on any particular container orchestration technology (e.g. Kubernetes). That said, the move towards Docker-based deployments is clearly a popular one, and we have found containers to be a useful way to bundle up and somewhat abstract away the two-part nature of full-stack Hoist UI applications. **Note:** the use of `appCode` throughout this section is a placeholder for the actual shortname assigned to your application. This is a short, camelCased variant of the longer `appName` and is set within the application source code via both the Gradle and Rsbuild configs. ## Setup/Prep ### Refresh and Lint JS Client We do this first to fail fast if the client code doesn't pass the linter checks, which is relatively common. This step could also run any preflight unit tests, etc. should you be diligent enough to have them. The below assumes `pnpm` (XH's recommended package manager) is used for the project, sub with the `yarn` or `npm` equivalents if using one of those instead. ```bash pnpm install pnpm lint ``` ## Server and Client Builds ### Build Grails server WAR with Gradle This step calls into a `build.gradle` script checked in with each project to build the Grails server-side into a WAR. This step takes an application version as well as an optional build tag, both of which are baked into the build: - `xhAppVersion` - an x.y.z version for releases, or `x.y-SNAPSHOT` for transient builds. - `xhAppBuild` - this is an optional arg that gets baked into the server and client and exposed as a variable for display in the built-in Hoist admin client. We use it to pass a git commit hash, which then provides another way to cross-reference exactly what snapshot of the codebase was used to build any given running application. Both version and build can be left unspecified, in which case the version will default to the version specified within the app's `gradle.properties` file (the build tag is nullable). Our convention is to leave `gradle.properties` checked in with the next snapshot version and have the builds override via a Gradle `-P` option when doing a particular versioned build. This means that "snapshot" builds can simply leave the argument off, versioned builds can supply a version via a CI parameter, and `gradle.properties` only needs to change in source control when a new major release moves us to a new snapshot. A Gradle wrapper should be checked in with the project and used according to Gradle best practices. ```bash ./gradlew war -PxhAppVersion=$APP_VERSION -PxhAppBuild=$APP_BUILD ``` The output is a `appCode-appVersion.war` file within `/build/libs`. ### Build TS Client with Rsbuild This step builds all the client-side assets (TS/CSS/static resources) with [Rsbuild](https://rsbuild.rs), taking the source and dependencies and producing concatenated, minified, and hashed files suitable for serving to browsers. Each project has an `rsbuild.config.mjs` file checked into the root of its `client-app` directory. It calls `configureRsbuild()` from [hoist-dev-utils](https://github.com/xh/hoist-dev-utils/blob/develop/configureRsbuild.js) to produce a complete Rsbuild configuration. The app's `build` script runs `rsbuild build --env-mode prod`. See that project for additional details. The Rsbuild CLI has no `--env key=value` flag. Build-time overrides arrive as `XH_*` environment variables instead, which the `readCliEnv()` helper maps onto `configureRsbuild()` options. Only a fixed set of options has a variable - see `readCliEnv()` for the list. `XH_APP_VERSION` and `XH_APP_BUILD` carry the appVersion and appBuild values detailed above, the most common options set at build-time. **Note:** this must run with `client-app` as its working directory: ```bash # Source commit hash from CI system (e.g. $GITHUB_SHA, $CI_COMMIT_SHA, etc.) APP_BUILD=${GIT_COMMIT:0:10} echo "Building $APP_VERSION $APP_BUILD" XH_APP_VERSION="$APP_VERSION" XH_APP_BUILD="$APP_BUILD" pnpm build ``` The output is a set of files within `/client-app/build/`. ## Docker Container Images The primary outputs of the overall build process are a pair of Docker containers, one for the Grails server and one for the client JS assets. These include the build assets and are tagged with the desired version, making them (as a pair) a complete and deployable instance of the application. Applications should be checked in with a `/docker/` directory containing Dockerfiles and configs for both the server and client containers. Both can be based on [public images published by XH](https://hub.docker.com/r/xhio/), although an inspection of [those](https://github.com/xh/xh-tomcat) [images](https://github.com/xh/xh-nginx) will show that they are very thin layers on top of the official Tomcat and nginx images on Docker Hub. ### Build and Publish Tomcat Docker Image The Grails server component is deployed within a Tomcat container. The app should have a minimal `/docker/tomcat/Dockerfile` (checked into source control) such as: ```dockerfile # Update the tag below to a fixed version for stability, or next to get a regularly updated build FROM xhio/xh-tomcat:next-tc10-jdk21 COPY setenv.sh bin/ COPY *.war webapps/ROOT.war ``` ------ #### Optional `setenv.sh` The optional `setenv.sh` referenced here can be checked in with the app project and used to set environment variables / Java Opts required by Tomcat. This typically contains a reasonable `Xmx` (max JVM heap) value for the app, either hard-coded or referencing an env variable: ```bash export JAVA_OPTS="$JAVA_OPTS -Xmx2G" ``` ------ That leaves the build with the job of generating a suitable tag for the container, running the Docker build, and then pushing to an appropriate (likely internal) Docker registry. The container tag should include the appCode + `-tomcat` to indicate that this is the Grails-side container. **Note:** this must run with `docker/tomcat` as its working directory: ```bash # Copy the WAR built above into the Docker context. cp ../../build/libs/*.war . # Determine an appropriate container tag. containerTag=$DOCKER_REGISTRY/$APP_CODE-tomcat:$APP_VERSION echo "Building Docker container $containerTag" docker build --pull -t "$containerTag" . # Note whether build was successful, push if so, return error if not. ret=$? if [ $ret -eq 0 ] then docker push "$containerTag" ret=$? else echo "Docker container build failed and was not pushed" fi # Cleanup and relay exit code to CI rm *.war exit $ret ``` ### Build and Publish nginx Docker Image The static JS resources are deployed within an nginx container. The app should have a minimal `/docker/nginx/Dockerfile` (checked into source control) such as: ```dockerfile # Update the tag below to a fixed version for stability, or next to get a regularly updated build FROM xhio/xh-nginx:next COPY app.conf $XH_NGINX_CONFIG_PATH/ COPY build/ $XH_NGINX_CONTENT_PATH/ ``` Note that the `$XH_` environment variables are set for convenience within the `xh-nginx` base image Dockerfile. ------ #### About the `app.conf` nginx configuration file The `app.conf` referenced above is an app-specific nginx configuration that should be checked in alongside the Dockerfile. It should setup the available routes to serve each bundled client app, handle any required redirects, and (importantly) include a _proxy configuration_ to pass traffic through from the nginx container to the Tomcat container. Hoist deploys typically bind only the nginx ports to the host machine, then link the nginx and Tomcat containers together via Docker/k8s so there's a single point of entry (nginx) for incoming requests. This means that no CORS or further, external proxy configuration is required to have the nginx-hosted client communicate with its Tomcat back-end. While the exact content of the `app.conf` file will vary depending on the app, a representative example is below: ```nginx server { server_name localhost; listen 80; root /usr/share/nginx/html; # Suppress nginx version in response headers. server_tokens off; # Security headers — consider adding the following, tailored to your deployment: # - Strict-Transport-Security (HSTS) # - Content-Security-Policy (CSP) # - Referrer-Policy # - Permissions-Policy # - X-Content-Type-Options # - X-Frame-Options # See Toolbox for a representative example. # Redirect root to platform-appropriate default client app - either app or mobile. location = / { if ($is_mobile) { return 302 https://$host/mobile/; } return 302 https://$host/app/; } # Entry points for this project's client apps, as built by Rsbuild. # Keep the list below in sync with entry-point files within `/client-app/src/apps`. location ~ ^/(?admin|app|mobile)(?:/|$) { # Add trailing slash if not present. Use explicit redirect w/leading https as this nginx is # behind a load balancer and would otherwise redirect first to http, resulting in an excess # redirect as the load balancer then sends the browser back to https. rewrite ^/(?admin|app|mobile)$ https://$host/$clientAppCodeNoSlash/ permanent; # When at the correct path, check if the requested path exists as a file - if so, serve it. # If not, serve index.html - this allows app routing to work - e.g. /admin/general/config # will load /admin/index.html and allow the client app code to interpret the rest. try_files $uri $uri/ /$clientAppCode/index.html; # Set cache expiry as per $expires var set by xh-nginx's `xh.conf` - this will cache JS/CSS # resources indefinitely while ensuring that index.html is re-read every time. expires $expires; } # Proxy to Grails back-end, run within the same pod/task as this nginx container. location ^~ /api/ { proxy_pass http://localhost:8080/; include includes/xh-proxy.conf; # Set Cookie policy to enforce HTTPS and control cross-site access. proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None"; # Explicitly disable caching for proxied API requests. expires epoch; } # Static JS/CSS/etc assets not matching a more specific selector above. location / { expires $expires; } } ``` Note that this example configuration: - Uses nginx config includes sourced from the base `xh-nginx` image. The base image also copies in [an overall config](https://github.com/xh/xh-nginx/blob/master/xh.conf) that enables gzip compression and sets the `$expires` variable referenced above. - Assumes a load balancer / ingress is handling SSL termination and that the nginx container is not directly exposed to the internet. While uncommon for Hoist apps, nginx can handle SSL if needed. - Sets up locations for three client-app entry points / bundles: `app`, `admin`, and `mobile`, with `mobile` being a dedicated app for mobile clients. Not all apps will have these three - some might lack a mobile client entirely, or ship other dedicated client apps. Uses named capture groups for cleaner routing logic. - Uses the `try_files` directive to attempt to service requests at sub-paths by handing back asset files if they exist, but otherwise falling back to `index.html` within that path. This allows for the use of HTML5 "pushState" routing, where in-app routes are written to the URL without the use of a traditional `#` symbol (e.g. ). - Creates a proxy endpoint at `/api/` to pass traffic through to the Tomcat back-end, which here is expected to be reachable from nginx via `localhost`. Uses `^~` to ensure this location takes priority over any regex locations. - The `/api/` path is expected by the JS client, which will automatically prepend it to the path of any local/relative fetch requests. This can be customized if needed on the client by adjusting the `baseUrl` param passed to `configureRsbuild()`. - The use of `localhost` is enabled via a deployment configuration that runs the two containers on the same pod / task / workload. This will vary based on the deployment environment. - Cookie security flags and cache disabling are set explicitly on proxied responses. ------ The build system now simply needs to copy the built client-side resources into the Docker context and build the image. Note the `-nginx` suffix on the container tag. **Note:** this must run with `docker/nginx` as its working directory: ```bash cp -R ../../client-app/build/ . containerTag=$DOCKER_REGISTRY/$APP_CODE-nginx:$APP_VERSION echo "Building Docker container $containerTag" docker build --pull -t "$containerTag" . docker push "$containerTag" ``` ### Docker Cleanup At this point the build is complete and new versioned or snapshot images containing all the runtime code have been pushed to a Docker registry and are ready for deployment. It might be beneficial to add one more step to clean up local Docker images on the build agent, to avoid them continuing to grow and take up disk space indefinitely. Note this forces Docker to pull the base images anew each time, which takes a small amount of time/bandwidth. It could be made more targeted if desired: ```bash docker system prune -af ``` ## Docker Deployment XH typically creates distinct targets for build vs. deploy, and configures deployment targets to prompt for the version number and/or Docker hostname. This process will differ significantly depending on the use (or not) of orchestration technology such as Kubernetes or AWS Elastic Container Service (ECS). Regardless of the specific implementation, the following points should apply: - Both `appCode-tomcat` and `appCode-nginx` containers should be deployed as a service / pair, and be kept at the same version. - The Tomcat container does not need to have any ports exposed/mapped onto the host (although it could if direct access is desired). - The nginx container typically exposes port 80, although if a load balancer or similar is also in play that might vary (and would require appropriate adjustments to the `app.conf` nginx file outlined above). - The nginx container must be able to reach the Tomcat container at the same name included in its `app.conf` file, typically `localhost` (as in the example above). - A persistent volume can be used to store logs if so configured. How that's done again will depend on the particular deployment environment and is beyond the scope of this doc. A sample deployment script using Docker directly: ```bash APP_CODE=appCode tomcatName=$APP_CODE-tomcat tomcatImage=$DOCKER_REGISTRY/$tomcatName:$APP_VERSION echo "Deploying $tomcatImage" # Stop and remove existing Tomcat container docker container stop $tomcatName docker container rm $tomcatName # Pull Tomcat image at specified version docker image pull $tomcatImage # Run Tomcat image and mount local docker volume for YML config / log storage docker run -d --name $tomcatName --mount type=volume,src=$APP_CODE,dst=/$APP_CODE --restart always $tomcatImage echo "Deploying $tomcatImage complete" nginxName=$APP_CODE-nginx nginxImage=$DOCKER_REGISTRY/$nginxName:$APP_VERSION echo "Deploying $nginxImage" # Stop and remove existing nginx container docker container stop $nginxName docker container rm $nginxName # Pull nginx image at specified version docker image pull $nginxImage # Run nginx image - link to Tomcat for proxying, expose ports, and mount local docker volume docker run -d --name $nginxName --link $tomcatName:$tomcatName -p 80:80 --mount type=volume,src=$APP_CODE,dst=/$APP_CODE --restart always $nginxImage echo "Deploying $nginxImage complete" # Prune Docker, cleaning up dangling images and avoiding disk space bloat docker system prune -af ```