It is not easy to explain the solution in short but I’ll try.
First what I did is to split monolithic app in 2 apps-backend and frontend but without creating two separate projects. It is still one JHipster project but backend is a private and frontend is in DMZ. That requires 2 different dockerfiles and 2 different fly.toml files - frontend and backend. Frontend and backend communicates through proxy configured in nginx.conf file on frontend side.
Now, the question was why it was working on Render and not on Fly.io?
The most important thing is that on Fly.io you have to manually (in the code) handle the
fundamental part of the CORS (Cross-Origin Resource Sharing) standard and which is implemented by all modern web browsers.
So in public class SecurityConfiguration (originaly made by JHipster while creating the project) I had to make some changes and it should look like
@Configuration
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfiguration {
private static final Logger log = LoggerFactory.getLogger(SecurityConfiguration.class);
private final JHipsterProperties jHipsterProperties;
public SecurityConfiguration(JHipsterProperties jHipsterProperties) {
this.jHipsterProperties = jHipsterProperties;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
// This bean is no longer strictly needed by the filter chain but is harmless to keep.
@Bean
MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
return new MvcRequestMatcher.Builder(introspector);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(withDefaults())
.headers(headers ->
headers
.contentSecurityPolicy(csp -> csp.policyDirectives(jHipsterProperties.getSecurity().getContentSecurityPolicy()))
.frameOptions(FrameOptionsConfig::sameOrigin)
.referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
.permissionsPolicyHeader(permissions ->
permissions.policy(
"camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr()"
)
)
)
.authorizeHttpRequests(authz ->
authz
.requestMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/contact-messages")
.permitAll()
.requestMatchers(
HttpMethod.GET,
"/api/blog-posts",
"/api/blog-posts/**",
"/api/skills",
"/api/projects",
"/api/project-images",
"/api/services"
)
.permitAll()
.requestMatchers(
"/api/authenticate",
"/api/register",
"/api/account/reset-password/init",
"/api/account/reset-password/finish"
)
.permitAll()
.requestMatchers(HttpMethod.GET, "/management/health", "/management/health/**", "/management/info")
.permitAll()
.requestMatchers("/api/admin/**", "/management/**")
.hasAuthority(AuthoritiesConstants.ADMIN)
.requestMatchers("/api/**")
.authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptions ->
exceptions
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(
Arrays.asList("http://localhost:9000", "http://localhost:8080", "https://*.fly.dev", "http://*.fly.dev")
);
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
And most critical backend (Spring boot) java class which gave me some headaches (JHipster create it wrongly) I had to remove redundant public CorsFilter corsFilter() which was overriding the CORS filter in above class:
@Configuration
public class WebConfigurer implements ServletContextInitializer, WebServerFactoryCustomizer<WebServerFactory> {
private static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class);
private final Environment env;
private final JHipsterProperties jHipsterProperties;
public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties) {
this.env = env;
this.jHipsterProperties = jHipsterProperties;
}
@Override
public void onStartup(ServletContext servletContext) {
if (env.getActiveProfiles().length != 0) {
LOG.info("Web application configuration, using profiles: {}", (Object[]) env.getActiveProfiles());
}
LOG.info("Web application fully configured");
}
/**
* Customize the Servlet engine: Mime types, the document root, the cache.
*/
@Override
public void customize(WebServerFactory server) {
// When running in an IDE or with ./mvnw spring-boot:run, set location of the static web assets.
setLocationForStaticAssets(server);
}
private void setLocationForStaticAssets(WebServerFactory server) {
if (server instanceof ConfigurableServletWebServerFactory servletWebServer) {
URL resource = getClass().getResource("/static/index.html");
// Only set the document root if we are running from the file system (protocol is "file")
if (resource != null && "file".equals(resource.getProtocol())) {
try {
File root = new File(resource.toURI());
// The resource is index.html, so we need its parent directory 'static'
File staticFolder = new File(root.getParent());
servletWebServer.setDocumentRoot(staticFolder);
} catch (URISyntaxException e) {
throw new RuntimeException("Unable to resolve static assets location", e);
}
}
// If the protocol is "jar", we do nothing. Spring Boot will handle it correctly.
}
}
// I had to remove this method which gave me the most headaches
// @Bean
// public CorsFilter corsFilter() {
// UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// CorsConfiguration config = jHipsterProperties.getCors();
// if (!CollectionUtils.isEmpty(config.getAllowedOrigins()) || !CollectionUtils.isEmpty(config.getAllowedOriginPatterns())) {
// LOG.debug("Registering CORS filter");
// source.registerCorsConfiguration("/api/**", config);
// source.registerCorsConfiguration("/management/**", config);
// source.registerCorsConfiguration("/v3/api-docs", config);
// source.registerCorsConfiguration("/swagger-ui/**", config);
// }
// return new CorsFilter(source);
// }
}
On frontend side there should be also nginx.conf (proxy settings for production environment):
# fly/frontend/nginx.conf (DNS + IP fallback, Round-Robin)
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip Settings
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# API Reverse Proxy with DNS + private IP fallback
location ~ ^/(api|management|v3/api-docs)/ {
# Fly.io internal DNS
resolver [fdaa::3]:53 ipv6=on valid=5s;
# Backend hostname
# set $backend_host "zoranstepanoski-prof-api.flycast";
set $backend_host "zoranstepanoski-prof-api.internal";
# Fallback backend private IPs (replace with your actual machine IPs)
set $backend_ip1 "[fdaa:2b:6918:a7b:464:6717:6324:2]";
set $backend_ip2 "[fdaa:2b:6918:a7b:4e5:13b9:ff5:2]";
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin $http_origin;
# Pass request to backend hostname first
proxy_pass http://$backend_host:8080;
# Retry on errors; fall back to private IPs if hostname fails
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 4;
proxy_next_upstream_timeout 2s;
# Optional: explicit failover sequence
# Nginx tries $backend_host first, then IP1, then IP2
# This uses the built-in "next upstream" mechanism
# If needed, you can duplicate proxy_pass with different conditions using 'if' blocks.
# Timeouts
proxy_connect_timeout 3s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
proxy_buffering on;
}
# SPA Fallback for Angular routing
location / {
try_files $uri $uri/ /index.html;
}
}
The reason why it was working on Render and not on Fly.io is that Render and Fly.io use different proxy technologies with different default configurations and architecture (splitting the backend and frontend).
This code is specific for Spring Boot and JHipster and there are some tweaks on application-prod.yml, Dockerfile for backend and frontend…
So, the code was working but it looks like that the difference between Fly.io and Render architecture was the biggest issue.
Anyway I made it work and thanks to [erlangga] and [lillian] who drew my attention to CORS in public class WebConfigurer.
If anyone else has the similar issues I’ll be glad to help!