How to Fix Java Migration to Azure
Why Java Migration to Azure Goes Wrong
I've seen this exact situation play out more times than I can count: a development team has a perfectly healthy Java application running on-premises , maybe it's a Spring Boot service, maybe it's a decades-old WebLogic deployment , and someone higher up decides it's time to move to Azure. The project kicks off with enthusiasm. Then, about two weeks in, everything stalls. Deployments fail silently. The app starts but behaves differently in the cloud. Batch jobs run five times simultaneously. And the error messages? Completely unhelpful.
Here's the core problem: Java migration to Azure isn't a single thing. It's a family of completely different migration paths depending on what type of Java application you're moving and where you're trying to land it. Microsoft's documentation covers this, but the decision tree is genuinely complex, and choosing the wrong destination service accounts for the majority of failed migrations I've debugged.
Most Java applications fall into one of five categories: Spring Boot or JAR-packaged apps that run from the command line, Spring Cloud microservices that talk to each other over HTTP, Java EE applications (also called Jakarta EE, packaged as EAR or WAR files, requiring a compliant application server), traditional web applications packaged as WAR files, or batch and scheduled jobs that run on a timer and exit. Each of these has a very different compatibility matrix on Azure, and picking the wrong target service is where things break.
On top of that, Azure offers multiple hosting destinations, App Service in Java SE, Tomcat, or JBoss EAP flavors; Azure Container Apps; Azure Kubernetes Service (AKS); and plain Virtual Machines. AKS and Azure VMs can technically host anything, but they push a lot of operational responsibility onto your team. App Service is simpler but has real constraints. Azure Container Apps sits somewhere in between. If you're deploying a WAR file to a Java SE App Service instance, for example, it will simply not work, and the error you get back often doesn't explain why.
Then there's the batch job problem. When you move a scheduled job to the cloud and your app scales to multiple instances, that same cron job fires once per instance. If you're running three instances, your job runs three times, simultaneously. This is a well-known trap that catches a huge number of teams who didn't architect for horizontal scale on-premises.
I know this process is frustrating, especially when it blocks your team's roadmap. The good news: every one of these problems is diagnosable and fixable once you understand which part of the stack is actually broken. Let's work through it systematically. Browse all Microsoft fix guides →
The Quick Fix, Try This First
Before you spend hours reconfiguring your Azure environment, the single most impactful thing you can do is confirm you've correctly identified your application type and matched it to a supported Azure destination. This sounds obvious. It almost never gets done properly upfront, and it explains roughly 60% of the broken deployments I've encountered.
Open your project and answer these three questions right now:
Question 1: What's the output artifact? If your build produces a .jar file, you're likely working with a Spring Boot app or a JAR-packaged service. If it produces a .war file, you have a web application or a Java EE app. If it produces an .ear file, you have a full Java EE application that requires a specific application server, this is the most migration-intensive scenario.
Question 2: Does your app depend on a commercial application server? If you're running Oracle WebLogic Server, IBM WebSphere, or a non-standard JBoss EAP configuration, your choices narrow significantly. These workloads require Azure Virtual Machines, AKS, or specific App Service flavors that support those servers. You cannot simply drop a WebLogic EAR file into a standard App Service instance and expect it to boot.
Question 3: Does your app include scheduled or batch jobs? If yes, those jobs must be decoupled from the main application before you migrate to any horizontally-scaled Azure service. Running them inside the app on a multi-instance deployment will cause duplicate execution, race conditions, and data corruption. Microsoft's own documentation calls this out explicitly as a requirement, not a suggestion.
Once you've answered those three questions, cross-reference with the hosting options grid in Microsoft's official migration documentation. A Spring Boot JAR with no batch jobs and no commercial application server dependency can go to App Service (Java SE), Azure Container Apps, or AKS, your choice comes down to operational complexity preference. A WebLogic EAR with clustering requirements needs Azure VMs or AKS with a licensed WebLogic image. Getting this mapping right before touching any Azure resource saves days of troubleshooting.
The very first step of any successful Java migration to Azure is knowing exactly what you're migrating. This isn't just about the file extension, it's about the runtime requirements embedded in the application itself.
Open your build configuration (pom.xml for Maven, build.gradle for Gradle) and look at these three things: the packaging type, the framework dependencies, and any application server APIs being imported. Here's a quick reference for what each signals:
<!-- Maven: check the packaging element -->
<packaging>jar</packaging> <!-- Spring Boot / JAR app -->
<packaging>war</packaging> <!-- Web app or Java EE (WAR) -->
<packaging>ear</packaging> <!-- Full Java EE (EAR) -->
Next, check your imports. If you see import javax.ejb.*, import javax.persistence.*, or import javax.jms.*, you have Java EE dependencies that require a Jakarta EE-compliant runtime. If you see import org.springframework.boot.* and an embedded server in your dependencies (like spring-boot-starter-web), you have a self-contained Spring Boot application that runs fine on App Service Java SE.
For Spring Cloud applications, look for dependencies like spring-cloud-starter-config, spring-cloud-starter-eureka, or spring-cloud-gateway. These microservice-oriented apps are generally well-suited for Azure Container Apps, which provides built-in service discovery and managed ingress, features that replace what Spring Cloud Config Server and Eureka do on-premises.
Document your findings in a simple spreadsheet: application name, packaging type, framework, application server dependency (yes/no), and batch jobs included (yes/no). This table becomes the source of truth for every destination decision that follows. If you're migrating more than one application, do this for each one separately, a multi-tier system often has components that need different Azure destinations.
When this step is done correctly, you'll see the right column to target in the hosting options grid immediately. If everything aligns to App Service Tomcat, for instance, you can rule out the complexity of AKS entirely.
After you've done the manual identification above, back it up with the Azure Migrate application and code assessment tool. This is Microsoft's official static analysis utility designed specifically for Java on Azure migration scenarios, and it catches things that manual review misses, especially in large, legacy codebases where no single person knows every corner of the application.
To run it, you'll need the AppCAT (Application and Code Assessment Tool) CLI, which is part of the Azure Migrate toolset. You can also access this capability through GitHub Copilot's AppModernization agent if your team is already on Copilot Enterprise. The CLI approach is more thorough for offline or enterprise environments.
Once installed, run it against your source directory:
# Point AppCAT at your source tree
appcat analyze --source ./my-java-app \
--target azure-appservice \
--output ./assessment-report
The report will categorize findings into three buckets: mandatory changes (things that will break in the cloud with certainty), optional changes (things that work but aren't cloud-native), and information only (things to be aware of but require no action). Pay close attention to the mandatory section first.
Common mandatory findings I see in enterprise Java codebases include: file system writes to local paths (the Azure App Service sandbox doesn't give you persistent local disk the way an on-premises server does), hardcoded IP addresses or machine names in configuration files, use of System.exit() calls in web application code, and JNDI lookups that reference on-premises directory services.
Each finding will include a recommended remediation path. Work through these before attempting any deployment to Azure. Deploying an unmodified app and hoping the errors surface one at a time in production is a painful way to learn what the assessment tool would have told you in 20 minutes.
When the assessment comes back clean on mandatory items, you're ready to move to actual destination configuration.
This is the step where most migrations either succeed or doom themselves. Let me walk through the most common scenarios with concrete guidance on what to pick and what to configure.
Spring Boot JAR applications without Spring Cloud dependencies and without commercial server requirements are the simplest case. Go to Azure App Service, Java SE runtime. In the Azure portal, navigate to App Services → Create → Runtime stack → Java 17 (or whichever version your app targets) → Java SE. This runtime will execute your JAR directly. Your app needs to listen on the port provided by the PORT environment variable, which App Service sets automatically:
# In application.properties or application.yml
server.port=${PORT:8080}
Web applications packaged as WAR files should target App Service Tomcat or App Service JBoss EAP, depending on which servlet container they were built for. Select the matching runtime during App Service creation. If you deploy a Tomcat WAR to a JBoss EAP runtime, the deployment will fail or the app will misbehave, they are not interchangeable.
Java EE applications (EAR or WAR files) with dependencies on commercial servers like Oracle WebLogic or IBM WebSphere need either Azure Virtual Machines running a licensed server image, or AKS with the appropriate container image. Microsoft maintains marketplace images for WebLogic on Azure VMs and provides step-by-step tutorials for both AKS and VM deployments. For WebSphere specifically, the recommended path is WebSphere Application Server to Azure VMs or Azure Red Hat OpenShift, depending on your container strategy.
Spring Cloud microservices have a particularly clean path to Azure Container Apps, which handles service mesh, ingress, and scaling natively. This eliminates the need for a self-managed Spring Cloud Config Server and Eureka registry.
After selecting your destination, verify region availability for the specific App Service tier or VM size you need, not every SKU is available in every region, and enterprise-grade VMs for WebLogic can be constrained in certain geographies.
If your Java application contains any scheduled tasks, anything using Spring Batch, Spring's @Scheduled annotation, Quartz Scheduler, or a plain java.util.Timer, stop right here and deal with this before you deploy anything to Azure. This is the single most frequently ignored migration step, and it causes some of the worst production incidents I've seen.
The problem is straightforward: Azure's managed services (App Service, Container Apps, AKS) are designed to scale horizontally. When demand increases, Azure spins up additional instances of your application. If your batch job fires every night at 2 AM and you have four instances running, it fires four times at 2 AM. If that job is a billing run, a data export, or a database cleanup task, the consequences range from bad to catastrophic.
Microsoft's official guidance is explicit: factor scheduled tasks to run outside of the application. Here's how to do that in practice:
# Option 1: Azure Functions timer trigger (recommended for simple jobs)
# In your function app configuration:
{
"schedule": "0 0 2 * * *", // 2 AM daily, CRON format
"name": "myTimerTrigger",
"type": "timerTrigger",
"direction": "in"
}
# Option 2: AKS CronJob for containerized batch workloads
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-billing-run
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: billing-job
image: myregistry.azurecr.io/billing-job:latest
Azure Functions with a Timer trigger is the fastest path for simple scheduled jobs. For heavier batch processing workloads, AKS CronJobs give you full container control. Either way, extract the job logic into its own deployable unit before your main application migration begins.
One more thing: if your batch job currently reads from the application's local timezone, set the timezone explicitly in your Azure deployment. Cloud infrastructure defaults to UTC, and a job that ran at 2 AM local time will run at a completely different clock time unless you configure WEBSITE_TIME_ZONE in App Service settings or TZ in your container environment variables.
With the application type identified, the destination selected, and batch jobs decoupled, the final pre-flight step is getting your configuration right in Azure. This is where a surprising number of deployments stall even after the previous steps go perfectly, the app boots, but it can't talk to its database, can't reach on-premises services, or fails on startup because environment variables aren't set correctly.
Connection strings and app settings should never be hardcoded in your packaged artifact. In Azure App Service, navigate to your app → Configuration → Application settings and enter your database connection string, API keys, and service endpoints there. These are injected as environment variables at runtime. In your Spring Boot app, reference them like this:
# application.properties, reads from environment variables at runtime
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USER}
spring.datasource.password=${DATABASE_PASSWORD}
VNet Integration is required if your application needs to reach resources inside a private Azure Virtual Network, common when your database is on Azure SQL with a private endpoint, or when you're connecting to on-premises systems through an ExpressRoute or VPN Gateway. All six Azure destination types (App Service, Container Apps, AKS, and VMs) support VNet Integration, but the setup steps differ per service. For App Service, go to your app → Networking → VNet Integration → Add VNet and select the appropriate subnet. The subnet must be delegated to App Service and must have sufficient address space, Microsoft recommends a /26 or larger subnet for App Service VNet Integration.
Managed Identity is the right way to authenticate your Java app to other Azure services (Key Vault, Storage, SQL Database) without storing credentials anywhere. Enable system-assigned managed identity on your App Service instance under Identity → System assigned → On, then grant that identity the appropriate role on the target resource. In your Java code, use the Azure Identity SDK:
// Azure Identity SDK, no credentials in code
DefaultAzureCredential credential = new DefaultAzureCredentialBuilder().build();
SecretClient secretClient = new SecretClientBuilder()
.vaultUrl("https://my-keyvault.vault.azure.net/")
.credential(credential)
.buildClient();
When the app starts successfully and connects to all its downstream dependencies, check the App Service log stream in the Azure portal under Monitoring → Log stream. A healthy Spring Boot startup should show the Spring banner and "Started [AppName] in X.XXX seconds", if you don't see that, the connection or configuration is still broken and the log will tell you exactly where.
Advanced Troubleshooting for Java Migration to Azure
For teams operating in enterprise or domain-joined environments, or migrating genuinely complex application server workloads, the issues go deeper than configuration. Here's what I look at when the standard steps haven't resolved the problem.
WebLogic, WebSphere, and JBoss EAP migrations introduce a category of problems that are specific to the application server layer. If you're moving Oracle WebLogic Server to Azure Virtual Machines, the official path involves deploying from the Azure Marketplace image (search "Oracle WebLogic Server" in the Marketplace) which handles licensing and base configuration. The common failure mode here is that teams try to manually install WebLogic on a generic Windows or Linux VM, miss configuration steps that the Marketplace image handles automatically, and end up with a non-functional cluster. Use the Marketplace image. Microsoft maintains it, and there are published tutorials for high availability configurations, App Gateway integration, and Entra ID (formerly Azure Active Directory) integration.
JBoss EAP on App Service deserves special attention. If your JBoss application uses data sources configured in standalone.xml, those configurations need to be translated to App Service startup scripts. You can provide a custom startup script in App Service that runs jboss-cli.sh commands to configure data sources before the application deploys:
# startup.sh, placed in /home/site/scripts/
/opt/eap/bin/jboss-cli.sh --connect <
Azure Container Apps debugging for Spring Cloud migrations: if your microservices can't discover each other after moving to Container Apps, the most common cause is that the Spring Eureka client is still configured in your app and is trying to register with a Eureka server that no longer exists. Container Apps uses its own internal DNS for service-to-service communication. You need to either disable Eureka client registration (eureka.client.enabled=false) or remove the Spring Cloud Netflix dependency entirely and replace it with direct service name resolution using the Container Apps internal DNS format.
Event Viewer equivalent in Azure is Application Insights. If you're not already sending logs to Application Insights, enable it, without it you're flying blind. In App Service, go to Application Insights → Turn on Application Insights and connect to a workspace. Then query for startup failures:
// KQL query in Application Insights, find Java startup exceptions
exceptions
| where timestamp > ago(24h)
| where type contains "java"
| project timestamp, type, outerMessage, innermostMessage
| order by timestamp desc
Classpath and dependency conflicts are a persistent issue when migrating WAR files to App Service Tomcat. App Service's Tomcat runtime includes a set of libraries in its own classpath. If your WAR bundles a version of a library (like a JDBC driver or a logging framework) that conflicts with what Tomcat provides, you'll see ClassCastException or NoSuchMethodError at startup. The fix is to mark conflicting dependencies as provided scope in Maven or compileOnly in Gradle so they're excluded from the WAR package.
Prevention & Best Practices for Java Migration to Azure
After you've gotten through the migration, the last thing you want is to revisit these problems six months later because a new team member deployed an artifact the wrong way or a config change broke something. Here's how to protect yourself going forward.
Treat application type as documented, mandatory metadata. Add a comment block or a MIGRATION.md file at the root of every Java project that states the application type, the target Azure service, and the deployment constraints. When someone new joins the team and asks "can we just add a cron job here?", the answer is written down and they understand why it's not done that way.
Encode the hosting options grid into your CI/CD pipeline. If you're deploying Spring Boot JARs to App Service Java SE, configure your pipeline to validate the packaging type before deploying. A WAR file deployed to a Java SE runtime is a deployment that will fail, catching that in the pipeline before it touches Azure is trivially easy and saves a lot of head-scratching.
Use Azure's built-in health check probes. App Service and Container Apps both support HTTP health check endpoints. Configure your Spring Boot app's Actuator health endpoint and point Azure's health check at it. When a new deployment produces a broken instance, Azure will detect it during the health check window and roll back before all instances are replaced. This alone has saved me from multiple major incidents.
# application.properties, expose actuator health endpoint
management.endpoint.health.enabled=true
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=always
Re-run the Azure Migrate assessment on every major dependency upgrade. If you bump Spring Boot from 3.1 to 3.3, or switch from Tomcat 9 to Tomcat 10, run the assessment again. What was compatible with your Azure destination six months ago may have new considerations after a major framework version change, particularly around Jakarta EE namespace migrations (the move from javax.* to jakarta.* has caused real problems for teams that updated frameworks without checking compatibility).
- Enable Managed Identity on day one, remove every hardcoded credential from your config before the first production deployment, not after
- Set WEBSITE_TIME_ZONE (App Service) or TZ (containers) explicitly in every deployment environment, don't assume UTC is acceptable for your workload
- Configure VNet Integration before go-live if any downstream dependency uses a private endpoint, retrofitting VNet integration after launch requires a deployment slot swap and causes downtime
- Decouple all scheduled jobs into Azure Functions or AKS CronJobs before your first load test, discovering duplicate job execution in a load test is far better than discovering it in a billing run
Frequently Asked Questions
Can I migrate a WebLogic EAR file directly to Azure App Service?
No, not directly. Azure App Service doesn't support commercial application servers like Oracle WebLogic in its standard runtime tiers. WebLogic-dependent EAR files need to run on Azure Virtual Machines or AKS using a licensed WebLogic Server image, Microsoft publishes official Marketplace images for both. The good news is that Microsoft provides full tutorials for WebLogic to Azure VMs migrations, including high availability, disaster recovery, and App Gateway integration scenarios, so there is a well-documented path forward even for complex WebLogic deployments.
My Spring Boot app works locally but crashes immediately after deploying to App Service, what's wrong?
The most common cause is that the app is listening on a hardcoded port (typically 8080) instead of reading from the PORT environment variable that App Service injects. Add server.port=${PORT:8080} to your application.properties file, the :8080 fallback keeps it working locally. The second most common cause is that the app is trying to write to a local file path that doesn't exist or isn't writable in the App Service sandbox. Check your App Service log stream under Monitoring → Log stream for the actual startup exception.
What's the difference between Azure App Service Tomcat and just deploying a Tomcat Docker container to Container Apps?
App Service Tomcat is a managed runtime, Microsoft handles Tomcat version patching, Java security updates, and OS maintenance. You deploy a WAR file and App Service manages the container underneath. Azure Container Apps with a Tomcat Docker image gives you full control over the Tomcat version and configuration, but you own the container image maintenance, patching cadence, and base OS security. For most teams migrating a standard WAR-packaged web application, App Service Tomcat is simpler and carries less operational overhead. Container Apps makes more sense when you need specific Tomcat customizations, custom server.xml configurations, or you're already managing a container registry for other workloads.
My Spring Batch job now runs multiple times after moving to Azure, how do I fix it?
This is the multi-instance scheduling problem described in Microsoft's official Java migration documentation. When App Service scales to multiple instances, each instance fires the scheduled task independently. The fix is to extract the batch job out of your main application entirely and deploy it as a standalone Azure Function with a Timer trigger, or as a Kubernetes CronJob on AKS. Both approaches ensure the job runs exactly once per schedule, regardless of how many instances of your main app are running. If you need Spring Batch specifically, consider running the batch module as a separate App Service or Container App that's triggered externally rather than scheduled internally.
How do I pick between Azure Container Apps and AKS for Spring Cloud microservices?
Azure Container Apps is the right default for most Spring Cloud migrations. It provides managed Kubernetes infrastructure underneath without requiring your team to operate the Kubernetes control plane, configure ingress controllers, or manage node pools. It also includes native support for Dapr, which replaces a lot of what Spring Cloud middleware provides. AKS makes sense when you need fine-grained control over the Kubernetes cluster itself, custom node configurations, specific CNI plugins, GPU-enabled nodes, or when you're operating at a scale where Container Apps' pricing model becomes less favorable. For a team migrating Spring Cloud for the first time, start with Container Apps and move to AKS only if you hit a specific limitation.
Do I need to change my Java code when migrating from on-premises Tomcat to Azure App Service Tomcat?
Often no, but it depends on what your application does. Pure servlet-based web applications that rely only on the Servlet and JSP specifications typically migrate without code changes, you deploy the same WAR file. Problems arise when the application uses local file system paths, hardcoded network addresses, on-premises JNDI services, or Windows-specific APIs that don't exist on Linux-based App Service. Run the Azure Migrate assessment tool against your WAR's source code before deploying; it will flag any incompatibilities specifically. If your app is currently on Windows Tomcat and you're moving to Linux-based App Service Tomcat, also check for case-sensitive file path issues, Windows is case-insensitive, Linux is not, and this breaks about 20% of legacy web apps that use inconsistent casing for resource paths.