How I Keep Our Factorio Server Updated With GitOps And OKD
How I replaced manual Factorio server updates with GitHub Actions, nightly version checks, and OKD ImageStream deployment triggers.
As a cloud maintainer for Tampa Devs, I am always looking for ways to make our OKD Kubernetes cluster more fun and useful. One of our community gems is the Factorio server we run for local players and anyone else who wants to join.
For those who may not know, Factorio is an addictive game about building factories, optimizing belts, and automating everything. Keeping the server on the latest version was the recurring pain. New releases were dropping every couple of weeks, and the old process involved manual Docker builds, pushes to GHCR, and pod deletes to force pulls.
Once the process was automated, Factorio took a pause for a few weeks before they pushed a new update until this month which the process finally got to see action. The result: zero-touch updates. GitHub Actions check Factorio's API nightly, bump the version file if needed, trigger a build to :latest, and OKD's ImageStream + Deployment triggers handle the rest. No SSH, no oc delete pod, no interruptions.
Everything lives in our public repo here: https://github.com/TampaDevs/cloud-development-gitops-apps/tree/main/game-servers/factorio
Let's walk through how it evolved and how you can replicate it.
The Old Manual Way (Painful)
- Check Factorio release notes or get told that the server isn't updated and no one can connect
- Build new Docker image with updated version
docker push ghcr.io/tampadevs/factorio:latestoc delete pod -n ontampa-devs-gaming -l app.kubernetes.io/name=factorio
It worked, but it was repetitive and error prone and I personally built the image locally and uploaded on Spectrum which was slow.
Phase 1 – Trigger Builds from a Version File
I introduced a single source-of-truth file: factorio-version.txt
2.0.73A GitHub Action watches for changes to this file and builds/pushes the image. The sections below rely on a few key points. First is the 'on' section which tells Github when to run our action. In this case a change to the main (or 'dev' branch for testing) and then if we are editing one of our key Docker related files. We do our authentication and login with our secrets and then we usefactorio-version.txtfile as our variable to use for the version to download and run our Docker build. Once complete, we push the new image to GHCR. 
name: 'Build and Push Factorio Docker Image'
on:
push:
# Trigger on pushes to 'main' and any branch starting with 'dev'
branches:
- main
- 'dev'
paths:
- 'game-servers/factorio/Dockerfile'
- 'game-servers/factorio/docker-entrypoint.sh'
- 'game-servers/factorio/factorio-version.txt'
...
jobs:
build-and-push-image:
name: Build and Push Factorio Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# ... setup and login steps are fine ...
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Read version from factorio-version.txt
id: extract_version
run: |
VERSION=$(cat ./game-servers/factorio/factorio-version.txt)
echo "version_tag=${VERSION}" >> $GITHUB_OUTPUT
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/factorio
tags: |
# Your tagging logic is fine
type=raw,value=${{ steps.extract_version.outputs.version_tag }}-${{ github.event.inputs.suffix }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.suffix != '' }}
type=raw,value=${{ steps.extract_version.outputs.version_tag }}-base,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=${{ steps.extract_version.outputs.version_tag }}-dev,enable=${{ github.ref == 'refs/heads/develop' }}
type=sha,prefix=${{ steps.extract_version.outputs.version_tag }}-,format=short
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: ./game-servers/factorio
file: ./game-servers/factorio/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
FACTORIO_VERSION_ARG=${{ steps.extract_version.outputs.version_tag }}
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,scope=${{ github.workflow }},mode=maxNow I can bump the version from my phone, commit, and the image is ready in minutes. 
Still manual: someone needs to restart the pod
Phase 2 – Nightly Version Detection
Factorio publishes latest releases at https://factorio.com/api/latest-releases.
I added a simple Node.js script in version-updater/check-and-update.js that:
- Fetches the API
- Compares against factorio-version.txt
- If newer → overwrites the file and commits/pushes
Scheduled GitHub Action runs every night:
name: Check Factorio Latest Version
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC daily
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.PAT_FOR_PUSH }} # needs write access
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
working-directory: game-servers/factorio/version-updater
- run: node check-and-update.js
working-directory: game-servers/factorio/version-updater
- name: Commit & Push if changed
run: |
git config user.name "TampaDevs Bot"
git config user.email "bot@tampadevs.org"
git add factorio-version.txt
git commit -m "chore: update Factorio to latest" || echo "No changes"
git pushChain reaction achieved: nightly check → version bump → build trigger → new image in GHCR.
Phase 3 – Hands-Off Deploys with OKD ImageStream + Triggers
This is where OKD shines.
We use an ImageStream that points to our remote GHCR image and has importPolicy.scheduled: true:
apiVersion: image.openshift.io/v1
kind: ImageStream
metadata:
name: factorio-base
spec:
tags:
- name: latest
from:
kind: DockerImage
name: ghcr.io/tampadevs/factorio:latest
importPolicy:
scheduled: true # ← enables periodic polling (cluster default: 15 min)
importMode: PreserveOriginalEvery ~15 minutes (cluster-wide default in OKD 4.x), the image controller checks GHCR. If :latest now points to a new digest, it imports the update → the ImageStreamTag refreshes.
The Deployment watches this via the special annotation:
apiVersion: apps/v1
kind: Deployment
metadata:
name: factorio-base
annotations:
image.openshift.io/triggers: >-
[{"from":{"kind":"ImageStreamTag","name":"factorio-base:latest"},
"fieldPath":"spec.template.spec.containers[?(@.name==\"factorio\")].image",
"paused":false}]
spec:
template:
spec:
containers:
- name: factorio
image: ghcr.io/tampadevs/factorio@sha256:... # gets auto-updated
imagePullPolicy: AlwaysWhen the ImageStream updates, OKD automatically patches the Deployment's pod template with the new digest → triggers a rollout (Recreate strategy in our case). Game server restarts cleanly with the latest version, persistent volume intact.
Why This Wins
- Pure GitOps: Everything declarative in the repo.
- Zero toil: No manual steps after initial setup.
- Reliable timing: Nightly checks + 15-min import window = always reasonably current.
- Community extensible: Fork it for Minecraft, Valheim, or any container that needs latest-image behavior.
Want to run your own? Jump into the repo, deploy via Helm, or PR your improvements. Let's keep automating the fun stuff.
Questions, ideas, or want help onboarding to the OKD cluster? Hit me up in TampaDevs Discord.
Happy building (and mining) 🚀