Sign inSign up

aconti90/factorio-headless

By aconti90

•Updated 1 day ago

Image
1

469

aconti90/factorio-headless repository overview

⁠factorio-server-docker

Multi-arch Docker images for the Factorio headless server, built automatically for every upstream release on both the stable and experimental channels.

CI Release watch Docker pulls Image size License

ghcr.io/aconti90/factorio-headless
docker.io/aconti90/factorio-headless *

* Optional — see "Setting it up as your own" below.

Architectureslinux/amd64, linux/arm64 — native builds, no emulation at runtime
Channelsstable and experimental, tracked independently
UpdatesAutomatic, within hours of an upstream release
Basedebian:trixie-slim, runs as a non-root user
Supply chainChecksum-verified downloads where upstream publishes sums, plus SBOM and signed build provenance

⁠Setting it up as your own

git remote add origin [email protected]:you/factorio-server-docker.git
scripts/init.sh                 # rewrites the aconti90/factorio-headless placeholders
git add -A && git commit -m "Initial commit" && git push

Then, once on GitHub:

  1. Settings → Actions → General → Workflow permissions → Read and write permissions, so the workflow can push to the registry.
  2. Actions → "Watch for Factorio releases" → Run workflow to publish the first images. After that it runs on its own schedule.
  3. Make the package public from the repo's Packages sidebar → package settings → Change visibility, if you want others to pull it. Public images on ghcr.io have no storage or bandwidth cost, and public repos get unlimited Actions minutes — the whole pipeline runs on the free tier.
  4. (Optional) To also publish to Docker Hub, add a repository variable named DOCKERHUB_USERNAME (your Docker Hub username) and a repository secret named DOCKERHUB_TOKEN (an access token with Read, Write, Delete scope, from Docker Hub's Account Settings → Security → New Access Token) under Settings → Secrets and variables → Actions. Leave both unset to publish to GHCR only — nothing else changes. Setting the config only affects future publishes (see step 2 above to trigger one immediately) — it won't retroactively publish versions already on GHCR.

⁠Why another Factorio image?

In September 2026 Wube shipped a native ARM64 Linux port⁠ of Factorio, headless server included — they used a Raspberry Pi 5 as the dedicated test runner for it. Until then, running a Factorio server on ARM hardware meant x86 emulation via box64 or QEMU, with the performance cost and "expect crashes and lag" caveats that came with it.

The existing community images predate that release and still build around the emulation path. This one ships genuinely native binaries for both architectures in a single manifest, so docker pull gets the right one automatically and a Pi runs Factorio at full speed.

⁠Quick start

docker run -d \
  --name factorio \
  -p 34197:34197/udp \
  -p 27015:27015/tcp \
  -v "$PWD/factorio-data:/factorio" \
  -e RCON_PASSWORD=change-me \
  --restart unless-stopped \
  --stop-timeout 120 \
  ghcr.io/aconti90/factorio-headless:stable

On first run the image lays out the data volume, renders a server-settings.json from the environment, generates a fresh map, and starts serving. Connect from the game via Multiplayer → Connect to address using your-host:34197.

With Compose, copy an example and edit it:

cp examples/docker-compose.yml docker-compose.yml
cp examples/.env.example .env      # set RCON_PASSWORD
docker compose up -d

⁠Tags

TagTracks
latestnewest stable release
stablenewest stable release
experimentalnewest experimental release
2, 2.0newest stable release in that series
2.0.77one exact release, never moves

Pin an exact version for anything you care about. The floating 2 / 2.0 / latest tags deliberately only ever follow the stable channel, so an experimental build cannot silently land on a server that asked for :2.

Important

**ARM64 is currently published on the experimental channel only.** At the time of writing, `stable` (2.0.77) has no arm64 headless build upstream — the download 404s — while `experimental` (2.1.19) has one. This is not a choice this project makes; the build workflow probes what upstream actually publishes for each release and builds only those architectures. On a Pi today that means using `:experimental`. When arm64 reaches stable, `:stable` gains it with no change here.

⁠Configuration

Everything is environment variables. server-settings.json is re-rendered from them on every start, so the environment is the single source of truth. Set UPDATE_CONFIG=false if you would rather hand-edit the file on the volume and have the image leave it alone.

⁠Server identity
VariableDefaultNotes
NAMEFactorioShown in the server browser
DESCRIPTIONA Factorio server running in Docker
TAGS—Comma-separated
MAX_PLAYERS00 = unlimited
⁠Visibility and access
VariableDefaultNotes
VISIBILITY_PUBLICfalseRequires FACTORIO_USERNAME + FACTORIO_TOKEN
VISIBILITY_LANtrue
FACTORIO_USERNAME—From your factorio.com profile
FACTORIO_TOKEN—From https://factorio.com/profile⁠
GAME_PASSWORD—Password to join
REQUIRE_USER_VERIFICATIONtrueVerify players against Factorio auth
ADMINS—Comma-separated usernames
WHITELIST—Comma-separated; enables whitelist mode when set
⁠Gameplay
VariableDefaultNotes
AUTO_PAUSEtrueSet false to keep the factory running while nobody is connected
AUTO_PAUSE_WHEN_PLAYERS_CONNECTfalse
ONLY_ADMINS_CAN_PAUSE_THE_GAMEtrue
AFK_AUTOKICK_INTERVAL0Minutes; 0 disables
⁠Saves
VariableDefaultNotes
SAVE_NAMEdefaultName used when generating the first map
GENERATE_NEW_SAVEtrueCreate a map when the volume has none
LOAD_LATEST_SAVEtrueLoad newest save rather than SAVE_NAME
AUTOSAVE_INTERVAL10Minutes
AUTOSAVE_SLOTS5
NON_BLOCKING_SAVINGfalseAvoids a stutter on save; uses more RAM
⁠Mods

Drop mod zips into the mods/ directory on the volume and restart the container — same as a normal Factorio install.

To keep mods already there up to date automatically, set UPDATE_MODS=true. On every boot the image checks each mod against the Factorio mod portal and replaces it if a newer version compatible with the running server exists. This only updates mods that are already present; it does not install new ones or resolve dependencies.

VariableDefaultNotes
UPDATE_MODSfalseCheck mods against the portal and update them on boot. Requires FACTORIO_USERNAME/FACTORIO_TOKEN.
MODS_IGNORE—Comma-separated mod names to exclude from updates

The update check runs before the server starts, so it delays startup and needs outbound HTTPS access to mods.factorio.com. If UPDATE_MODS=true and FACTORIO_USERNAME/FACTORIO_TOKEN aren't set, the container refuses to start. A mod the portal doesn't recognize (e.g. a private/local mod) is left untouched with a logged warning, not treated as an error.

⁠Container
VariableDefaultNotes
PUID / PGID845Set to your host user so saves stay editable
PORT34197Game port (UDP)
RCON_PORT27015
RCON_PASSWORDgeneratedA random one is generated and logged if unset
BIND—Bind to a specific interface
EXTRA_ARGS—Passed straight to the server binary

⁠Running it on a Raspberry Pi 5

examples/docker-compose.pi.yml is a working starting point. The things that actually matter:

  • Use :experimental for now — see the note above on arm64 availability.
  • Put the data volume on something that isn't an SD card if you can. Autosaves are the dominant write load, and SD cards wear out under it. An NVMe HAT or a USB SSD is a meaningful reliability upgrade; if you're stuck on SD, raise AUTOSAVE_INTERVAL and lower AUTOSAVE_SLOTS.
  • RAM is not the constraint. A headless server is comfortable in well under 2 GB for a normal base. The Pi's single-core speed is what eventually caps how large a factory you can run before UPS drops below 60.
  • Factorio is UDP. An HTTP-oriented tunnel (Cloudflare Tunnel and friends) will not carry it. Either forward UDP 34197 on your router, or put the server and your clients on a WireGuard/Tailscale network and connect over that — which also avoids exposing the port publicly at all.

⁠Observability: stats and logs dashboards

examples/docker-compose.observability.yml runs Factorio alongside a full Grafana stack — Prometheus for factory/server stats (item production rates, UPS, player count, power), Loki for readable server logs — with both dashboards already provisioned. Unlike the other compose examples, this one sets AUTO_PAUSE=false by default, so the factory keeps running (and the dashboards keep showing something) even with nobody connected — a stats dashboard for a paused world isn't very useful.

Warning

**Turning this on disables achievements for the save.** The exporter polls the game over RCON console commands (`/sc`), and Factorio disables achievements for a save the moment any console command runs on it — regardless of what the command actually does. If you care about achievements on a particular save, don't point the exporter at it.
cp examples/.env.observability.example examples/.env
# edit examples/.env: set RCON_PASSWORD and GRAFANA_ADMIN_PASSWORD
docker compose -f examples/docker-compose.observability.yml up -d

On a Raspberry Pi (arm64), uncomment FACTORIO_TAG=experimental in your .env too — stable has no arm64 build yet (see Tags⁠ above), so the factorio service fails to pull without it.

On some Docker versions on a Raspberry Pi, up -d also fails with no matching manifest for linux/arm64/v8 for the Prometheus/Loki/Promtail/ Grafana images — those upstream images publish an arm64 build but don't tag it with an explicit "v8" variant, and some Docker versions match that strictly. If you hit this, uncomment DOCKER_PLATFORM=linux/arm64 in your .env and retry; it's a no-op on hosts that don't need it.

examples/.env is shared with the other compose examples in this directory — docker-compose.yml and docker-compose.pi.yml also read it for RCON_PASSWORD. Setting GRAFANA_ADMIN_PASSWORD there alongside it doesn't conflict with those; it's just additive.

External stacks on the factorio-tunnel network: the observability stack creates a Docker network called factorio-tunnel, and a separate out-of-repo compose file can join it as external: true (e.g. a Cloudflare Tunnel stack) to reach Grafana at http://factorio-grafana:3000 without publishing a new host port. The observability stack must be started first since it creates the network.

Open Grafana at http://localhost:3000 (or whatever GRAFANA_PORT you set, if you already had something on 3000) and log in as admin with the password you set as GRAFANA_ADMIN_PASSWORD (only the password is configured — the username is always admin). Both the Factorio Stats and Factorio Logs dashboards are already there, under the "Factorio" folder — no manual datasource or dashboard setup.

To share just the stats dashboard (e.g. on a public status page) without exposing the logs dashboard or Grafana login access, use Grafana's share/export menu's externally-shared-dashboard option (exact wording drifts across Grafana versions — look for "Share externally" or similar) and enable it from the Stats dashboard. The Logs dashboard stays behind normal Grafana authentication.

⁠See it live

Here's the Factorio Stats dashboard, running publicly⁠ off this exact setup:

Item production, consumption, and powerKills, losses, turret status, evolution, and research
Fluid rates, entities built, and pollution

Note

That's my own dashboard, running on a Raspberry Pi on my home server. I don't play all that often, and I use editor mode to test stuff on the server — so if you catch it looking odd, the base may just be idle, mid experiment, or paused.

The exporter polls Factorio over RCON, so it needs the same RCON_PASSWORD the factorio service uses — no separate credential.

The power metrics scan every electric pole on the map each poll; on a very large factory this adds measurable overhead, especially on a Raspberry Pi — raise POLL_INTERVAL_SECONDS if you notice it.

⁠Keeping a world running while you work

Factorio has no failure state you can wander into: with AUTO_PAUSE=false the server simulates continuously, and a base left alone for two hours is simply a base that produced more. The only thing that can spoil it is biters, and that is a world-generation setting rather than something the server can change later:

# Before first start — edit the map-gen settings on the volume, then let the
# image generate the map from them.
docker run --rm -v "$PWD/factorio-data:/factorio" ghcr.io/aconti90/factorio-headless:stable \
  sh -c 'cat /factorio/config/map-gen-settings.json'

Set autoplace_controls.enemy-base.size to "none" for a world with no biters at all, or leave them in and set peaceful_mode: true in config/map-settings.json so they never initiate attacks. Both must be decided before the map is generated; changing them afterwards needs console commands that disable achievements.

⁠Updating

The image does not self-update — that would mean a server restarting itself without warning. Pull and recreate when you want to:

docker compose pull && docker compose up -d

Saves are forward-compatible, so a newer server loads an older save fine. Going backwards is not supported by Factorio; keep an autosave from before an upgrade if you plan to roll back.

Clients must match the server version exactly, so if you track :experimental, your players need the experimental branch on Steam too.

⁠How the automation works

  schedule (every 6h)
        │
        ▼
  release-watch.yml ──► scripts/resolve-release.sh
        │                  ├─ GET /api/latest-releases      (version per channel)
        │                  ├─ range-probe each download URL (which arches exist)
        │                  └─ look up published sha256 sums
        │
        ├─ group channels by version, skip anything already in the registry
        ▼
  build.yml ──► buildx ──► ghcr.io + Docker Hub*  (+ SBOM; provenance attestation on ghcr.io only)

* Docker Hub publishing is optional — set via the DOCKERHUB_USERNAME repo variable and DOCKERHUB_TOKEN repo secret. Unset, the pipeline publishes to ghcr.io only.

Three design decisions worth knowing about, since they're the ones that make it correct rather than merely working:

Architecture availability is discovered, not assumed. Upstream does not ship every architecture for every release. The workflow range-probes each download URL (a one-byte request, not a 400 MB one) and builds the manifest from what actually exists, so a missing arm64 build is a smaller manifest rather than a red run.

The registry is the state. Nothing is committed back to the repo to remember what has been built; the workflow asks ghcr.io directly via docker manifest inspect. There is no file to drift out of sync with reality.

Downloads run on the build host, not under emulation. The downloader stage is pinned to $BUILDPLATFORM, so fetching and unpacking a ~400 MB tarball always happens natively; only the final image layer is target-arch. Cross-building arm64 on free x86 runners costs essentially nothing, which is what keeps this workable on GitHub's free tier — public repos get unlimited Actions minutes and ghcr.io storage for public images.

Wube asks integrators⁠ to poll api/latest-releases rather than the download endpoint, which is rate-limited. This does that, four times a day.

⁠Building locally

# Current stable, your native architecture
docker build ./docker \
  --build-arg FACTORIO_VERSION="$(./scripts/resolve-release.sh stable | jq -r .version)" \
  -t factorio-server:local

# Cross-build both architectures (requires binfmt/QEMU registered)
docker buildx build ./docker \
  --platform linux/amd64,linux/arm64 \
  --build-arg FACTORIO_VERSION=2.1.19 \
  -t factorio-server:local

scripts/resolve-release.sh [stable|experimental] is the same script CI uses and is useful on its own — it prints the current version, which architectures exist for it, and any published checksums.

⁠Contributing

See CONTRIBUTING.md⁠.

⁠License

MIT — see LICENSE⁠. Factorio is the property of Wube Software; this project packages the freely redistributable headless server and is not affiliated with or endorsed by Wube.

Tag summary

Content type

Image

Digest

sha256:e6f1f3e61…

Size

202.9 MB

Last updated

12 days ago

docker pull aconti90/factorio-headless