Using Ansible to deploy Factorio servers

Published 2024-03-11 on Cara's Blog - Permalink

Hello again! I’m writing this while in Australia for project work! Recently, I took it upon myself to automate a lot of virtual machine configuration via Ansible. This includes a common configuration for all servers, which sets the following:

  • MOTD
  • SSH configuration
  • Backups
  • LDAP login (if enabled, still working on getting this right)
  • Firewall (if enabled, again, still working on getting this right)

LDAP configuration can probably take up a whole post on its own, which will come when it actually Works™️. The other part of this, however, is figuring out that often the best way for me to document the way servers are configured is to build an Ansible role step-by-step as I read through the documentation and figure things out. This helps to (1) test the Ansible role and make sure it’s repeatable, (2) get down all the steps to go from nothing to something, and (3) help me learn more about Ansible. The first service I tried this with was, as you may have guessed from the title, Factorio.

What is Factorio?

Factorio is an automation and factory-building game where player(s) must build a mostly or entirely-automated factory after crash-landing on an alien planet. It has the potential to get rather technical really quickly, which has a certain appeal to myself and my friends. It, importantly, supports dedicated servers that can be downloaded without authentication. I wanted to be able to configure dedicated servers by modifying my Ansible inventory, and have it be set up right away.

How do we get there?

First, let’s talk about how I structure my Ansible playbooks. At the root of the repository are two files and two folders. It looks a little something like this:

drwxr-xr-x 3 csalter csalter 4096 Feb  7 06:21 group_vars
-rw-r--r-- 1 csalter csalter 1485 Mar 11 10:09 inventory.yaml
-rw-r--r-- 1 csalter csalter  287 Mar 11 10:09 playbook.yaml
drwxr-xr-x 5 csalter csalter 4096 Mar 11 10:09 roles

group_vars/ holds legacy variables and my Ansible Vault, and isn’t really updated much outside of the vault. inventory.yaml defines my Ansible inventory, and associated variables. In this file is a segment defining a factorio group, with each host within that group receiving a YAML array of different servers. That variable looks something like this:

1
2
3
4
5
6
7
          factorio_servers:
            - name: "season_1"
              factorio_version: "1.1.104"
              server_port: 34197
            - name: "iqp_1"
              factorio_version: "1.1.104"
              server_port: 34198

Each server gets a defined name that’ll be used to determine filesystem location later on, as well as the save name and systemd service name. It also gets a version, so theoretically any Factorio version is up for use. Finally, there’s a server port definition to make sure that multiple servers on the same machine don’t conflict.

Next up is playbook.yaml. The relevant part here isn’t really that interesting:

1
2
3
4
5
6
7
- name: Factorio configuration
  become: yes
  hosts: factorio
  roles:
    - factorio
  tags:
    - factorio

It just tells ansible the name of the role, that it needs to become the superuser to do the tasks in the role, and to run the factorio role on the factorio group.

Now that that’s done, we can take a look at the way that the factorio role is structured. Ansible requires that roles have, at a minimum, a tasks/main.yaml file to define the steps taken by the role. Having all the steps in one file isn’t really ideal for readability, so in all of my roles, I separate out the steps into different files, leaving tasks/main.yaml looking something like this:

1
2
3
---
- include_tasks: users.yaml
- include_tasks: factorio.yaml

This will include the tasks from tasks/users.yaml, followed by tasks/factorio.yaml. tasks/users.yaml is pretty basic, it just sets up a service user:

1
2
3
4
5
6
7
8
---
- name: Create factorio service user
  user:
    name: factorio
    state: present
    shell: /sbin/nologin
    system: true
    home: /srv/factorio

The real bulk of this role is in tasks/factorio.yaml. It contains all the steps needed to set up multiple Factorio servers on one machine. Note that this currently targets RHEL deriviatives, but only for the firewall portion. That can be easily swapped out for iptables, ufw, or your chosen firewall setup.

In general, the steps to set up a headless Factorio server are:

  1. Download the correct version of the server
  2. Create required directories and extract the server files there
  3. Generate a map
  4. Configure systemd
  5. Allow ports through firewall

Let’s go through this task-by-task to set up all the Factorio servers. First up is getting the right version of the game. Wube Software1 releases the headless server files for free on the Factorio website. The releases follow a consistent URL scheme, which makes it easy to download the right version using Ansible’s get_url task:

1
2
3
4
5
6
- name: Download headless factorio
  get_url:
    url: "https://www.factorio.com/get-download/{{ item.factorio_version }}/headless/linux64"
    dest: /tmp/factorio_headless_{{item.factorio_version}}.tar.xz
  with_items:
    - "{{ factorio_servers }}"

This task won’t download the file if it’s already been downloaded. with_items is used to make sure we get all of the required versions. Next, we create all the server directories:

1
2
3
4
5
6
7
8
- name: Create game server directories
  file:
    path: "/srv/factorio/{{ item.name }}"
    state: directory
    owner: factorio
    group: factorio
  with_items:
    - "{{ factorio_servers }}"

This is required separately from extracting the tarball, as Ansible’s unarchive task doesn’t create target directories. Speaking of unarchive:

1
2
3
4
5
6
7
8
9
- name: Extract headless factorio
  unarchive:
    src: "/tmp/factorio_headless_{{item.factorio_version}}.tar.xz"
    dest: "/srv/factorio/{{ item.name }}"
    owner: factorio
    group: factorio
    remote_src: True
  with_items:
    - "{{ factorio_servers }}"

The remote_src option is required here to have it look on the remote server for the tarball, and not locally. Potentially, there could be bandwidth savings to be had by downloading to the Ansible controller, but the tarball is only 57MB for version 1.1.104. Next up is the systemd service. Slightly out of order, but this is roughly where my “document using Ansible as I figure it out” falls apart somewhat. I created the service and tried to start it before I figured out that the save needed to be created manually. In any case:

1
2
3
4
- name: Create factorio service
  template:
    src: templates/factorio.service
    dest: "/etc/systemd/system/factorio@.service"

This references the factorio.service template, which creates a dynamic2 systemd service for the game servers:

[Unit]
Description=Factorio Game Server - %i
After=network.target

[Service]
Type=simple
ExecStart=/srv/factorio/%i/factorio/bin/x64/factorio --start-server %i
WorkingDirectory=/srv/factorio/%i/factorio
User=factorio
Group=factorio

[Install]
WantedBy=multi-user.target

This uses a nifty feature of systemd where you can use %i as a placeholder in your service or socket or whatever, save it as name@.{unit type}, and refer to it in systemctl invocations as name@foo. That’ll cause %i to be replaced with foo in your services. Anyways, this service uses this to avoid having to create individual systemd services for each server. You can refer to the servers as factorio@iqp_1, instead. Now, we need to create the initial save files for the servers:

1
2
3
4
5
6
7
8
9
- name: Create saves if not exist
  become: yes
  become_user: factorio
  command:
    cmd: "/srv/factorio/{{ item.name }}/factorio/bin/x64/factorio --create {{ item.name }}"
    chdir: "/srv/factorio/{{ item.name }}/factorio"
    creates: "/srv/factorio/{{ item.name }}/factorio/{{ item.name }}.zip"
  with_items:
    - "{{ factorio_servers }}"

This uses an interesting feature of command, namely creates. If the file named by creates exists, command won’t run. Here, we use it to only generate save files if one doesn’t already exist. Finally, we create the server configuration (which allows us to set the server port we defined in the inventory):

1
2
3
4
5
6
7
8
- name: Create server configuration
  template:
    src: templates/factorio-config.ini
    dest: "/srv/factorio/{{ item.name }}/factorio/config/config.ini"
    owner: factorio
    group: factorio
  with_items:
    - "{{ factorio_servers }}"

And enable all the servers:

1
2
3
4
5
6
7
8
- name: Reload systemd state
  systemd_service:
    daemon_reload: true
    state: started
    enabled: true
    name: factorio@{{ item.name }}
  with_items:
    - "{{ factorio_servers }}"

Now we’re done, right? Unfortunately, I was also wrong and forgot that on RHEL derivatives, firewalld is both enabled by default and also defaults to no open ports, so we need to allow the UDP ports through the server firewall:

1
2
3
4
5
6
7
8
- name: Allow all server ports through firewall
  firewalld:
    port: "{{ item.server_port }}/udp"
    permanent: true
    immediate: true
    state: enabled
  with_items:
    - "{{ factorio_servers }}"

With that, we’re finally done. There are funky things you can do with SRV records to deal with non-standard ports, as well as port forwarding if you’re behind a NAT, but that’s out of scope for this. If you want to see the full tasks/factorio.yaml file, you can find that here.


  1. The publisher and developer of Factorio ↩︎

  2. I’ve found conflicting answers on what to call name@.service units, so this is what I’m going with. If you have a better name, please email me! ↩︎


Articles from my webring

gccrs: An alternative compiler for Rust

This is a guest post from the gccrs project, at the invitation of the Rust Project, to clarify the relationship with the Rust Project and the opportunities for collaboration. gccrs is a work-in-progress alternative compiler for Rust being developed as part…

via Rust Blog November 7, 2024

Status update, October 2024

Hi! This month XDC 2024 took place in Montreal. I wasn’t there in-person, but thanks to the organizers I could still ask questions and attend workshops remotely (thanks!). As usual, XDC has been a great reminder of many things I wanted to do but which got bur…

via emersion October 21, 2024

Post-OCSP certificate revocation in the Web PKI

Introduction Today, TLS certificates in the Web public key infrastructure (PKI) have long validity: almost all remain valid for at least three months! An attacker compromising a certificate early enough in its lifetime1 keeps it compromised for months. Cer…

via Posts on Seirdy’s Home September 25, 2024

Generated by openring