Provisioning a Valheim server with Terraform

The Valheim logo.

My friends and I decided we were going to give Valheim, an early-access survival game, a try. Since many of us are parents now and we’re in a socially distancing world, colocating for an evening of fun was out of the question. I decided to use the event as an excuse to practice my DevOps skills.

I use Terraform at work for a variety of things, but I don’t write enough of it to be completely comfortable. As such, I decided to use Terraform to provision a dedicated server for us. This article documents how I went about it using DigitalOcean and Terraform.

Create an SSH key

When provisioning cloud servers, SSH is your best friend. When you need to perform any maintenance on the server, you will need SSH to connect to it. Since this is a single-purpose machine that we’ll be provisioning, let’s create a single-purpose SSH key to go with it.

ssh-keygen -t ed25519 -f ~/.ssh/id_valheim -P ""

This command generates an elliptic curve SSH key that we will use to connect to the server. It uses an empty passphrase since its only purpose is to connect to this throwaway server. We will use the DigitalOcean API to upload this key to them for use with our server.

Configure the Terraform provider

First, you will need to create an API token on DigitalOcean. You’ll want to keep this in a safe place because once you generate it you will not be able to see the token again. Once you have it, you’ll want to set it as an environment variable. I use direnv for this purpose and, as such, put the environment variable in a .envrc file in my project. That looks like the following:

export DIGITALOCEAN_TOKEN="mytokenthatigotfromdigitalocean"

Once you have that environment variable set, you can use the DigitalOcean Terraform provider. To configure Terraform to use that provider, you will need to specify it as a required provider1 like so:

terraform {
  required_providers {
    digitalocean = {
      source = "digitalocean/digitalocean"
      version = "~> 2.5"
    }
  }
}

And then declare it as a provider:

provider "digitalocean" {
  # export DIGITALOCEAN_TOKEN
}

I like to add that comment in the provider block to state where its credentials come from; it’s not necessary. For those of you following at home, you can place all of the configuration in a single .tf file or design your own scheme for organizing your blocks.

To install the provider, initialize Terraform with:

terraform init

Set up the SSH key

The first resource we need to create is the SSH key. This is because DigitalOcean creates the server from an image that uses cloud-init to set default SSH keys for the root user. Without it, the other commands won’t work.

To create the SSH key resource, we specify it like so:

resource "digitalocean_ssh_key" "valheim" {
  name = "valheim"
  public_key = file("~/.ssh/id_valheim.pub")
}

This takes the file on our local machine located at ~/.ssh/id_valheim.pub and uploads it as an SSH key to DigitalOcean. If you chose to name your key something different in the previous step, use that filename here. The name shows up in DigitalOcean’s security settings to help you identify it later. Pick something memorable.

Create the server

Once we have the provider set up, we can write a recipe to provision the server. First, we have to set up the SSH key that we generated in the last step

For our purposes, a bit of anecdotal research showed that others successfully run a four-person server on a 2 CPU / 4 GiB server. This is a standard server size from DigitalOcean called s-2vcpu-4b. With that knowledge, we can write the following resource:

resource "digitalocean_droplet" "valheim" {
  image = "ubuntu-20-04-x64"
  name = "valheim"
  region = "nyc1"
  monitoring = true
  size = "s-2vcpu-4gb"
  tags = ["game"]
  ssh_keys = [digitalocean_ssh_key.valheim.fingerprint]
}

The image here is the latest Ubuntu LTS at the time of this writing. The name helps to identify your droplet in DigitalOcean’s cloud console. In my case, three of the four of us were playing from Ohio so I picked the New York City 1 data center (or region). Setting the monitoring flag to true enables DigitalOcean’s new agent-based resource monitoring that is more fine-grained than the default. I prefer it to their older metrics. The tags are a way to keep your droplets organized in the cloud console.

The most important field here is the ssh_keys field. This takes the SSH key that we uploaded in the previous resource and links it to the droplet so that we can log into the droplet via SSH. Without it, DigitalOcean will email you a root password, but that is incompatible with the rest of the Terraform configuration.

Configure the server

I very much label this section as caveat lector, reader beware. Because I am treating this server as a “create it and delete it later” server, I am using a Terraform provisioner to provision the box. As stated in the documentation, Hashicorp only intends you to use these provisioners as a last resort. If this were for a mission-critical system, I would probably use Packer to build an image and deploy it instead. But that’s a little too heavy for my current task, so here we are.

There are three steps that we will perform. First, we’ll create some necessary directories and attempt to temporarily disable the automated updates for Ubuntu Server. Next, we’ll upload some configuration files to the server. Lastly, we’ll install Docker and start the Valheim service.

Create necessary directories

Running simple scripts in the Terraform provisioner is simple. The remote-exec provisioner generates a script file and runs it with the shell. We’ll use this functionality for the first step:

resource "digitalocean_droplet" "valheim" {
  # ...
  provisioner "remote-exec" {
    inline = [
      "sed -i 's/1/0/g' /etc/apt/apt.conf.d/20auto-upgrades",
      "mkdir -p /opt/valheim",
      "mkdir -p /etc/sysconfig"
    ]

    connection {
      type = "ssh"
      user = "root"
      host = self.ipv4_address
      private_key = file("~/.ssh/id_valheim")
    }
  }
}

The first line is a hack that attempts to beat a race condition between the provisioning scripts and the Ubuntu automatic updates service. If the automatic update occurs just before any of our calls to Apt, we won’t be able to obtain the lock on the repositories and package caches and our scripts will fail. So we use sed to disable the automatic upgrades service, then create two directories.

The first directory, /opt/valheim, will contain our game data. The second contains an environment file for the Systemd unit we will use to manage the server process.

The connection block configures the SSH connection that we use to run the provisioner. This will be in each of the provisioner blocks that follow, as I don’t think there is a way to share connection information between provisioner blocks without something like Terragrunt. The block says “create an SSH connect using the root user at the IPv4 address that we just created and use the id_valheim key.”

Now that we have the basics, we can upload a Systemd service unit and its accompanying environment file. This takes two blocks, one for each file.

Upload Systemd unit

To upload the Systemd unit that manages the Valheim server, we can add this provisioner to the droplet resource:

resource "digitalocean_droplet" "valheim" {
  # ...
  provisioner "file" {
    source = "conf/valheim-server.service"
    destination = "/etc/systemd/system/valheim-server.service"

    connection {
      type = "ssh"
      user = "root"
      host = self.ipv4_address
      private_key = file("~/.ssh/id_valheim")
    }
  }
}

The file provisioner takes a local file (or content string) and uploads it as a file to the resource. In this case, it takes the Systemd unit below, which we store in conf/valheim-server.service at the root of our Terraform project.

[Unit]
Description=Valheim Server
After=docker.service
Requires=docker.service

[Service]
TimeoutStartSec=0
EnvironmentFile=-/etc/sysconfig/valheim-server
ExecStartPre=-/usr/bin/docker stop %n
ExecStartPre=-/usr/bin/docker rm %n
ExecStartPre=/usr/bin/docker pull lloesche/valheim-server
ExecStart=/usr/bin/docker run \
          --name %n \
          --rm \
          -v /opt/valheim:/config:Z \
          -p 2456-2458:2456-2458/udp \
          -e SERVER_NAME \
          -e SERVER_PORT \
          -e WORLD_NAME \
          -e SERVER_PASS \
          -e SERVER_PUBLIC \
          --dns $DNS_1 \
          --dns $DNS_2 \
          lloesche/valheim-server
ExecStop=/usr/bin/docker stop %n
Restart=always
RestartSec=10s

[Install]
WantedBy=multi-user.target

This is the default Systemd unit for the Docker container we use to run the server. It reads environment variables from an environment file that we’ll upload next and starts the Docker container with them. Running through line-by-line:

  1. -v /opt/valheim:/config:Z mounts our game data directory as a volume as an unshared bind-mount within the container
  2. -p 2456-2458:2456-2458/udp forwards the default game ports from the host to the container
  3. -e SERVER_NAME will configure the name of the server within Valheim
  4. -e SERVER_PORT sets the first port that Valheim listens on
  5. -e WORLD_NAME sets the name of the world file for your identification
  6. -e SERVER_PASS sets the password to enter the server, which must be 5+ characters according to the manual
  7. -e SERVER_PUBLIC is a boolean flag for whether the server should show up in the public server list
  8. The -e --dns lines set the DNS servers that the container should use
  9. lloesche/valheim-server is the name of the container image to run

The rest of the unit file attempts to ensure the container is the only copy running and tells the unit to load the environment file that we upload next.

Upload environment file

The second file provisioner uploads the environment file that configures the Systemd unit:

resource "digitalocean_droplet" "valheim" {
  # ...
  provisioner "file" {
    content = templatefile("conf/valheim-server.conf.tpl", {
      server_name = var.server_name,
      server_pass = var.server_pass,
      world_name = var.world_name
    })
    destination = "/etc/sysconfig/valheim-server"

    connection {
      type = "ssh"
      user = "root"
      host = self.ipv4_address
      private_key = file("~/.ssh/id_valheim")
    }
  }
}

This reads a configuration file template (shown below) from the conf/valheim-server.conf.tpl file at the root of your Terraform project and inserts some Terraform variables. To enable the variables, we need three variable blocks in our Terraform file:

variable "server_name" {
  type = string
}

variable "server_pass" {
  type = string
}

variable "world_name" {
  type = string
}

You can set these as environment variables with the pattern TF_VAR_<variable>, which I recommend doing in your .envrc file if you use direnv.

The template itself looks like:

DNS_1=8.8.8.8
DNS_2=8.8.4.4
SERVER_NAME="${server_name}"
SERVER_PASS="${server_pass}"
SERVER_PORT=2456
SERVER_PUBLIC=1
WORLD_NAME="${world_name}"

These are the environment variables that you see referenced above in the System unit.

Now that we have all of that on the server, it’s time to install Docker and start the service!

Install Docker and start Valheim

This section is the longest part of provisioning the server because it does several things. First, Docker is not available from the default Ubuntu repositories so we have to add its repository and configuration, which takes a few commands. Then, we need to install Docker. Finally, we enable the Valheim service and re-enable the automatic upgrade system.

We do this with the following provisioner block:

resource "digitalocean_droplet" "valheim" {
  # ...
  provisioner "remote-exec" {
    inline = [
      "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -yq apt-transport-https ca-certificates curl gnupg-agent",
      "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -",
      "echo 'deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable' > /etc/apt/sources.list.d/docker.list",
      "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -yq docker-ce docker-ce-cli containerd.io",
      "systemctl daemon-reload",
      "systemctl enable --now valheim-server.service",
      "sed -i 's/0/1/g' /etc/apt/apt.conf.d/20auto-upgrades"
    ]

    connection {
      type = "ssh"
      user = "root"
      host = self.ipv4_address
      private_key = file("~/.ssh/id_valheim")
    }
  }
}

Dissecting this line-by-line, we see the following:

  1. apt-get update [...] gnupg-agent installs the prerequisites for adding the Docker repository in a headless way so we aren’t prompted for input
  2. curl [...] | apt-key add - adds the Docker GPG key to the Apt keychain
  3. echo [...] > /etc/apt/sources.list.d/docker.list adds the Docker repository to Apt
  4. apt-get update [...] containerd.io installs Docker, again in a headless way
  5. systemctl daemon-reload ensures that Systemd has a reference to our new Docker service
  6. systemctl enable --now valheim-server.service enables the Valheim server, which makes it start up upon reboot, and starts the server now
  7. sed [...] re-enables Ubuntu’s automatic updates

Once this provisioner finishes, Terraform finalizes its actions. We’ll have a Valheim server running in approximately four minutes! To connect, put in the IP address of your droplet with the port :2457, confusingly.

Conclusion

One of the ways that I improved my skills as both a developer and systems operator was through running private services for my friends and me, like video game servers. This article shared a pared-down version of the DevOps setup I used for setting up a dedicated Valheim server. Using a single local tool, Terraform, we were able to go from nothing to a fully functioning server running on DigitalOcean.

Granted, this simple setup skips a larger step in provisioning and uses a hack to try to beat the race against Ubuntu’s auto-updates. Converting those steps to a repeatable process was beyond the scope of what I wanted to do, but there are many ways to do so with a variety of different tools. As an exercise, see what you can come up with!

If you’re interested in seeing my full setup using CloudFlare for DNS and DigitalOcean firewalls, you can find it on GitHub. Also, you can sign up for DigitalOcean and receive a $100 credit for 60 days so you can test this for free2.

Do you ever use your leisure time for things like this? Have you ever used a cloud provider to host a game server?


  1. As of this writing, I am using Terraform v0.14. It and its prior version, v0.13, require different provider setup than prior versions. To check which version you’re using, run terraform -version

  2. Full disclosure: that link is a referral link. If you sign up and spend $25, I will receive a $25 credit. If you’d prefer not to participate in that referral, here is a link without referral