Building Grails Custom Resource Handlers for Static Content
Grails applications often begin with static files stored in the project and served through the framework’s normal asset pipeline. That approach works well for stylesheets, JavaScript bundles, images, and small downloads. As an application grows, however, static content may move to a separate directory, a mounted volume, an object store, or a tenant-specific location. A custom resource handler gives you a controlled URL, storage rule, caching policy, and security boundary for that content.
This pattern is useful for product manuals, user-uploaded images, generated reports, public datasets, and versioned frontend assets. It also gives Java and Groovy developers a practical way to understand how Grails connects with Spring MVC. The Grails learning hub provides additional framework material that can help when adapting the examples to a particular Grails release.
Why Custom Static Delivery Matters
A normal static resource setup assumes that the application knows where files live and that every file can be exposed using the same rules. Real applications frequently need a different arrangement. A public marketing image may be safe to cache for a year, while a generated PDF might need a short lifetime. A shared asset volume may also sit outside the application archive so that a deployment does not erase uploaded files.
A custom handler separates the public URL from the physical storage location. For example, /media/** can map to /srv/grails/media/, while /manuals/** maps to a classpath directory packaged with the application. The browser sees stable URLs, and the server retains freedom to change the storage layer later.
This approach is particularly valuable when deploying several Grails instances behind a load balancer. If one instance writes a file to its local disk, another instance may not find it. A shared filesystem, object storage adapter, or deployment-time synchronisation process must be chosen deliberately rather than hidden behind an accidental default.
Choose The URL And Storage Contract
Start by defining the URL contract before writing configuration. Decide whether files will be addressed as /media/logo.svg, /downloads/invoice-1042.pdf, or /assets/v3/app.js. Keep public paths descriptive and avoid exposing operating-system paths, database identifiers, or temporary directory names.
The storage type determines the handler design. Classpath resources are suitable for files committed with the application. A filesystem resource is appropriate for a mounted directory or a read-only release folder. Cloud object storage is often better for large or user-generated content, although it may be served through signed links or a CDN rather than a local Spring resource handler.
For an application hosted in Sydney or Melbourne, an Australian cloud region can reduce latency for local users and simplify data-residency discussions. That does not automatically satisfy every privacy or contractual obligation, but it is a useful architectural choice for files that should remain in Australia. The same decision should be recorded for backups, replicas, and CDN edge locations.
A clean contract also defines missing-file behaviour. A request for a missing image should usually produce a normal 404 response, not a redirect to a login page or a generic HTML view. This distinction matters to browsers, crawlers, monitoring tools, and frontend code.
Register A Spring Resource Handler
In a Grails application, a custom handler can be registered through a Spring configuration class. Place the class under src/main/groovy, where Grails can discover it, and implement WebMvcConfigurer. The following example maps /downloads/** to a directory outside the application archive:
package example
import groovy.transform.CompileStatic
import org.springframework.context.annotation.Configuration
import org.springframework.http.CacheControl
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import java.util.concurrent.TimeUnit
@CompileStatic
@Configuration
class StaticResourceConfiguration implements WebMvcConfigurer {
@Override
void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler('/downloads/**')
.addResourceLocations('file:/srv/grails/downloads/')
.setCacheControl(
CacheControl.maxAge(1, TimeUnit.HOURS)
.cachePublic()
)
}
}
The trailing slash on the filesystem location is important because it identifies a directory. The handler removes the /downloads/ prefix and resolves the remaining path beneath that directory. ResourceHttpRequestHandler, which Spring uses behind this configuration, can provide suitable content types and support efficient resource delivery.
For a classpath directory, use a location such as classpath:/public-downloads/. Files placed under src/main/resources/public-downloads/ are then packaged into the application. A filesystem path is more suitable for files updated without rebuilding the application. Keep environment-specific directories in configuration rather than hard-coding them when possible:
String downloadRoot = grailsApplication.config
.getProperty('app.downloadRoot', String, '/srv/grails/downloads')
registry.addResourceHandler('/downloads/**')
.addResourceLocations("file:${downloadRoot}/")
The exact configuration access pattern can vary between Grails versions, so confirm it against the version used by the project. The important principle is that the handler receives a complete, trusted root and never a root assembled from arbitrary request input.
Control Paths, Types, And Cache Headers
A resource handler should expose only the directory it needs. Do not map a broad filesystem root such as /, /home, or an entire application workspace. Even though Spring resolves resources relative to the configured location, a narrow root makes the intended security boundary easier to inspect and maintain.
Never accept a request parameter that becomes part of the resource root. User input should select a relative filename beneath a fixed directory, not choose the directory itself. For higher-risk content, store an internal identifier in the database, validate the requested filename against an allow-list, and resolve the final resource through a service before serving it.
File names generated by an application should be predictable only where that is useful. UUID-based names prevent collisions and make it harder for users to guess neighbouring files, but an unguessable name is not a substitute for authorisation. Private content still needs access control.
Caching should match how often content changes. Fingerprinted assets such as app-8f31c.js can use a long lifetime because a new filename is created when the content changes. A stable URL such as /downloads/current-price-list.pdf needs a short cache lifetime or an explicit invalidation strategy. Cache-Control, Last-Modified, and conditional requests help reduce bandwidth without forcing users to receive stale files indefinitely.
Connect Resource Delivery With Security
A public resource handler generally bypasses controller actions, so its access rules must be considered separately from ordinary Grails controllers. If /downloads/** is intended to be public, configure Spring Security and any Grails security rules to allow it deliberately. If the files are private, do not assume that adding a handler automatically checks the current user.
For protected content, a controller or a dedicated endpoint may be safer. It can load the file record, verify ownership or role membership, and return a Spring Resource only after authorisation succeeds. Another option is to generate a short-lived signed URL from object storage. This adds a small amount of application work but makes access decisions explicit.
Pay attention to information leakage through filenames, directory listings, and error messages. A response should not reveal whether a private file exists to an unauthorised user. Returning the same carefully chosen response for missing and forbidden private resources may be appropriate for sensitive documents.
Australian applications should also treat uploaded documents as potentially containing personal information under the Australian Privacy Principles. A customer statement, Medicare-related document, or employee record should not become public merely because it was placed under a path that a browser can reach. Review retention, access logging, and deletion requirements along with the Grails mapping.
Test The Handler In A Grails Workflow
Begin with an integration test that creates a temporary directory, writes a known file, starts the application context, and requests the public URL. Verify the status, body, content type, and cache headers. Test a missing file as well as nested paths, spaces in filenames, and filenames containing encoded characters.
A useful test set includes a text file, an image, a PDF, and a file with an unfamiliar extension. Confirm that binary content is not altered and that a browser receives a sensible Content-Type. Test conditional requests when your application relies on browser caching or a reverse proxy.
Security tests should attempt path traversal patterns such as ../secret.txt, encoded traversal, and redundant path separators. The handler and the underlying resource resolver should reject paths outside the configured root, but an automated test protects the application if configuration or framework dependencies change.
Document the URL rules beside the code. Clear prose is part of the operational interface, and principles from clear technical writing are useful when explaining which files are public, how long they are cached, and who owns the storage directory. In a team spread between Brisbane, Perth, and remote locations, precise documentation prevents local development assumptions from becoming production defects.
Deploy Behind Proxies And CDNs
A custom handler may work perfectly with ./gradlew bootRun and still behave differently in production. Check how the reverse proxy forwards paths, whether it strips a prefix, and whether it handles range requests. Nginx, Apache, a cloud load balancer, and a CDN can each add or replace headers.
If a CDN caches /assets/**, use immutable filenames or a deliberate purge process. Do not cache authenticated downloads in a shared public cache. Forward only the headers needed for content negotiation and caching, and ensure that compressed responses do not produce incorrect Content-Encoding values.
For a filesystem-backed handler, create the directory during deployment, set its owner and permissions, and verify that the Grails process can read it. A container image should usually treat static files as read-only. Uploaded files belong in persistent storage mounted independently from the container, or in an object store designed for that purpose.
Australian network conditions also make observability worthwhile. Users on regional NBN connections may experience a larger impact from oversized images or reports than users in central Sydney offices. Record response status, resource size, cache hits, and latency without logging sensitive filenames. A small monitoring check from an Australian location can detect a broken mount or proxy rule before customers report missing assets.
Improve The Design As Requirements Grow
A local resource handler is a strong starting point for release assets and modest document collections. It becomes less suitable when files are very large, frequently uploaded, replicated across several application nodes, or delivered to users around the world. At that point, move the storage implementation to S3-compatible object storage, a managed file service, or a CDN origin.
Keep the public URL stable during that migration. The Grails handler can initially read from a local directory, then from object storage, while the browser continues to request /downloads/**. This reduces frontend changes and allows the storage transition to happen behind a well-defined boundary.
Use separate mappings for content with different policies. For example, /assets/** may serve immutable application resources, /images/** may serve public product images, and /private-files/** may be handled through an authorised controller. Combining all three under one permissive mapping creates ambiguity around caching and access.
A small configuration class can therefore support a surprisingly broad range of Grails applications. The essential habits are consistent roots, explicit security, correct cache semantics, integration tests, and deployment-aware storage. With those foundations, custom static content delivery remains understandable as the application moves from a laptop to a multi-instance production platform.
Add a focused resource configuration to your Grails project, test it with real file types and hostile paths, then document the URL and storage contract for the rest of the team. This gives your static content a reliable boundary today and leaves room for a CDN or cloud storage migration when the application reaches its next stage.