Automating Minecraft Backups inside a Docker Container

Up-close photograph of an old PC terminal listing the filesystem
🛠️
This post is going to get more technical than usual. Buckle up.

After days of beating my head against a wall, I finally got automatic backups working in my Minecraft Bedrock dedicated server container. What seemed initially like a simple solution required learning an entirely new scripting language.

Limitations of #!/bin/bash ⛔

Bash scripting is the bread and butter of Linux server automation. It's incredibly powerful, and relatively easy to learn. Until now, I had used bash scripts exclusively to automate tasks inside containers. However, my naive self thought it would be cool to automate backups of your Minecraft world inside the docker container.

The vision was simple: all a user would need to do is set two environment variables:

  • BACKUPS=true
  • BACKUP_INTERVAL=24

... and the container would automatically create a timestamped copy of your existing map inside a backup directory.

What I didn't account for is that Minecraft Bedrock needs to temporarily pause database writes in order to safely copy the world files without risking corruption. This means the script needs to interact with the running server binary console.

I started spinning my wheels trying to get various scripts to attach to the running Docker CMD, but nothing was working. Plus, this script needed to listen interactively to the server's feedback. Days of Duck Duck Go... ...ing yielded no solution. I was blazing a new frontier here.

🕓
My initial plan was to use cron to schedule backups, but if there is a way to get cron to send commands to the Minecraft console, I haven't found it.

A Little Help From Strangers 🤝

The solution presented itself while looking at a more popular Bedrock container (itzg/docker-minecraft-bedrock-server). Their solution is to manage backups on the host. Okay, that's reasonable. But I wanted to make something turnkey. Luckily, when someone requested this feature, some solutions suggested using expect to interact with the server.

Admittedly, I had never even heard of 'expect' before this, but when I started learning about it, it seemed like just thing I was looking for.

Machinations of #!/usr/bin/expect ⚙️

Expect, if you aren't familiar, is a scripting language that listens for an expected output from a command, and executes the next part of the script accordingly.

The syntax is a bit different than what I'm used to, and there seems to be a lack of educational material on the internet about how to use it. However, after many, many, failed iterations here's how I got it working:

  1. The container launches and CMD executes bootstrap.sh
  2. bootstrap.sh checks to see if backups are enabled.
    1. If they are, it kicks off server.exp (the expect script).
    2. If they aren't, it kicks off the normal server.sh which updates the server.properties files and runs the server binary.
  3. Assuming backups are enabled and we chose option "a", server.exp looks like this:
#!/usr/bin/expect

set timeout 10
spawn /server.sh

while true {
    expect {
        "Server started." {
            send "save hold\r"
            expect "Saving..."
            send "save query\r"
            expect {
                "Data saved." {
                    exec ./backup-map.sh
                }
                timeout {
                    exp_continue
                }
            }
            send "save resume\r"
        }
        timeout {
            exec ./backup-pause.sh
            send "save hold\r"
            expect "Saving..."
            send "save query\r"
            expect {
                "Data saved." {
                    exec ./backup-map.sh
                }
                timeout {
                    exp_continue
                }
            }
            send "save resume\r"
        }
    }
}

Deep Dive 🤿

Since a lot of my issues learning expect scripting stemmed from people not explaining exactly how their scripts work, I'm going to go through this line by line for posterity:

#!/usr/bin/expect

This is the shebang, telling the OS to use expect to execute this script.

set timeout 10

This sets how long (in seconds) the script will wait for each "expect" statement. We can also specify a different action in case of a timeout. More on that later.

spawn /server.sh

This "spawns" a new process. In this case, we are spawning server.sh which updates the server.properties files and runs the server binary.

while true {

This begins "while" loop. This statement is very common in scripting to trick a script into an infinite loop, since true will always be true.

expect {

This begins an "expect" statement. We use brackets to allow for branching options. It will listen for each option and execute the following commands accordingly.

"Server started." {

This is the first "expect" case. If the script gets "Server started.", it will execute the following commands:

send "save hold\r"

Sends the "save hold" command to the bedrock server. This tells the server to stop writing data to the database in preparation to copy the world files. The "/r" sends a carriage return (aka return key) at the end of the command.

expect "Saving..."

In response to a save hold, the bedrock server will return "Saving...", which the script is listening for.

send "save query\r"

Sends the "save query" command to the bedrock server. This asks the server if it's done putting a hold on the database. The "/r" sends a carriage return (aka return key) at the end of the command.

expect {
  "Data saved." {
      exec ./backup-map.sh
  }
  timeout {
  exp_continue
  }
}

This is a nested expect command with two options. If it hears back "Data saved." from the server, it will execute backup-map.sh, which actually copies the world files. If it doesn't hear back within the timeout period (10s remember?), it executes "exp_continue", which continues on with the rest of the script.

send "save resume\r"

Sends the "save resume" command to the bedrock server. This lets the server know it's okay to write to the database again, writing any changes it accumulated in memory during the hold. The "/r" sends a carriage return (aka return key) at the end of the command.

timeout {
  exec ./backup-pause.sh
  send "save hold\r"
  expect "Saving..."
  send "save query\r"
  expect {
    "Data saved." {
      exec ./backup-map.sh
    }
    timeout {
      exp_continue
    }
  }
  send "save resume\r"
}

This entire block is a copy of the last one, except executes in the case of a timeout. However, this one has a key difference. Before it starts the save hold, it executes another script:

exec ./backup-pause.sh

backup-pause.sh basically serves the sole purpose of pausing the script for BACKUP_INTERVAL x 3600 seconds. I originally told the expect script to 'sleep', but expect handles this differently than bash and resulted in weird behavior.

In summary, the first block executes the save script as soon as the server starts, and the second block waits the for BACKUP_INTERVAL and then saves. The "while true" loop causes this to go on indefinitely as long as the container is running.

Conclusion 📝

While it's a bit frustrating that it took me 4 days to come up with 38 lines of code, I learned of the existence of a new scripting language, how to use it for something of medium complexity, and applied it in production.

So if you spin up this container, please utilize the automated backups. They are really convenient and I worked hard on them. 😓