Power of the shell: Automating Minecraft Plugin Deployment with Azure DevOps

Deploying Minecraft plugins for PaperMC servers manually gets tedious fast. You need to build your code, copy the .jar file and add it to the servers plugin folder. Then lastly you need to restart the server by killing the java process and then starting it back up.

This can create pain points for logged in users that gets disconnected. Because lets be real, if you’re doing this manually, you don’t want to send a server message that you are restarting in X minutes and wait before uploading the new version of the plugin.

You probably already use some sort of version control for managing the plugin code.

So in this post I will talk about setting up a a build pipeline using Azure DevOps to build and store the .jar artifact.

First some details about the plugin and server host:
The plugin is written using Java and it’s using Gradle as its build system. I’m using git as VC and pushing the code to an Azure DevOps repo.
The server is hosted on a Windows virtual machine in Azure that is protected using a virtual network.

That is why this was the architecture I landed on:

  • Azure DevOps Pipeline – Builds the plugin JAR on every push to master
  • PowerShell Script – Runs on your Windows server, polls for new builds
  • mcrcon – Sends RCON commands to the PaperMC server announcing shutdowns to players.

Java project setup

First I created an Azure pipelines file to run the Gradle build command. This also needs to be set up so it can build using JDK 21 and triggering when a new version is pushed to the master branch, and uploading the artifact.

# azure-pipelines.yml
trigger:
- master

pool:
vmImage: 'ubuntu-latest'

variables:
GRADLE_USER_HOME: $(Pipeline.Workspace)/.gradle

steps:
- task: JavaToolInstaller@0
inputs:
versionSpec: '21'
jdkArchitectureOption: 'x64'
jdkSourceOption: 'PreInstalled'
displayName: 'Set up JDK 21'

- task: Cache@2
inputs:
key: 'gradle | "$(Agent.OS)" | **/build.gradle*'
restoreKeys: |
gradle | "$(Agent.OS)"
path: $(GRADLE_USER_HOME)
displayName: 'Cache Gradle packages'

- task: Gradle@3
inputs:
gradleWrapperFile: 'gradlew'
tasks: 'clean build'
publishJUnitResults: false
displayName: 'Build with Gradle'

- task: CopyFiles@2
inputs:
sourceFolder: '$(Build.SourcesDirectory)/build/libs'
contents: '*.jar'
targetFolder: '$(Build.ArtifactStagingDirectory)'
displayName: 'Copy JAR to staging'

- task: PublishBuildArtifacts@1
inputs:
pathToPublish: '$(Build.ArtifactStagingDirectory)'
artifactName: 'plugin-jar'
publishLocation: 'Container'
displayName: 'Publish JAR artifact'

Configuring the windows server

I needed to set a couple of system environment variables. The first one AZURE_DEVOPS_PAT that stored an access token to Azure DevOps to be able to download the file. Then RCON_PASSWORD which is used to connect to the PaperMC server.

Which reminds me that these properties in the server.properties file needs to be set

enable-rcon=true
rcon.port=25575
rcon.password=your-secure-password

Then I created the deployment script. The script downloads the artifact from Azure and checks for changes in the build version. If found it announces a shutdown in 120 seconds, stops the server gracefully, downloads the plugin artifact, copies it to the correct folder and restarts.


# poll-artifacts.ps1
$org = "pt"
$project = "ACDC%202026"
$pipelineId = "000"
$pat = $env:AZURE_DEVOPS_PAT
$lastBuildFile = ".\lastBuild.txt"

# Server configuration
$serverDir = "C:\Minecraft\Server"
$pluginsDir = "$serverDir\plugins"
$rconHost = "localhost"
$rconPort = 25575
$rconPassword = $env:RCON_PASSWORD
$shutdownDelaySeconds = 120

# Function to send RCON command to PaperMC server
function Send-RconCommand {
    param([string]$Command)
    
    # Using mcrcon or similar tool (install separately)
    # Alternative: Use a PowerShell RCON module
    & mcrcon -H $rconHost -P $rconPort -p $rconPassword $Command
}

function Announce-Shutdown {
    param([int]$Seconds)
    
    Send-RconCommand "say §c[SERVER] Restarting in $Seconds second(s) for plugin update!"
    
    for ($i = $Seconds; $i -gt 0; $i-=5) {
        if ($i -le 15) {
            Send-RconCommand "say §c[SERVER] Restarting in $i seconds!"
        }
        Start-Sleep -Seconds 5
    }
    
    Send-RconCommand "say §c[SERVER] Restarting NOW!"
    Start-Sleep -Seconds 5
}

function Stop-MinecraftServer {
    Send-RconCommand "save-all"
    Start-Sleep -Seconds 3
    Send-RconCommand "stop"
    
    # Wait for server process to exit
    $timeout = 60
    $elapsed = 0
    while ((Get-Process -Name "java" -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*$serverDir*" }) -and $elapsed -lt $timeout) {
        Start-Sleep -Seconds 2
        $elapsed += 2
    }
    
    # Force kill if still running
    Get-Process -Name "java" -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*$serverDir*" } | Stop-Process -Force
    Get-Process -Name "cmd" -ErrorAction SilentlyContinue | Where-Object {
        $_.MainWindowTitle -match "paper|minecraft"
    } | Stop-Process -Force
}

function Start-MinecraftServer {
    Set-Location $serverDir
    Start-Process -FilePath "cmd.exe" -ArgumentList "java -Xms2048M -Xmx2048M -jar paper.jar nogui" -WorkingDirectory $serverDir
}

# Main polling logic
$headers = @{
    Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$pat"))
}

$buildsUrl = "https://dev.azure.com/$org/$project/_apis/build/builds?definitions=$pipelineId&statusFilter=completed&resultFilter=succeeded&`$top=1&api-version=7.0"
$response = Invoke-RestMethod -Uri $buildsUrl -Headers $headers

if ($response.count -gt 0) {
    $latestBuild = $response.value[0]
    $buildId = $latestBuild.id

    $lastProcessed = if (Test-Path $lastBuildFile) { Get-Content $lastBuildFile } else { "0" }

    if ($buildId -ne $lastProcessed) {
        Write-Host "New build detected: $buildId"
        
        # Announce and shutdown
        Announce-Shutdown -Seconds $shutdownDelaySeconds
        Stop-MinecraftServer
        
        # Download new artifact
        $artifactUrl = "https://dev.azure.com/$org/$project/_apis/build/builds/$buildId/artifacts?artifactName=plugin-jar&api-version=7.0"
        $artifact = Invoke-RestMethod -Uri $artifactUrl -Headers $headers
        
        Invoke-WebRequest -Uri $artifact.resource.downloadUrl -Headers $headers -OutFile "plugin.zip"
        Expand-Archive -Path "plugin.zip" -DestinationPath $pluginsDir -Force
        Remove-Item "plugin.zip"
        
        # Start server
        Start-MinecraftServer
        
        $buildId | Out-File $lastBuildFile
        Write-Host "Update complete, server restarted"
    }
}

Then it was just a matter of scheduling the script in windows to run every 15 minutes. And now we have a fully automated build pipeline using Azure, PowerShell and ALM.

Output of PaperMC server console. The messages showing “[SERVER] Restarting…” are visible by the people logged in to the server.