Multi-arch Docker images for the Factorio headless server, built automatically for every upstream release on both the stable and experimental channels.
ghcr.io/aconti90/factorio-headless
docker.io/aconti90/factorio-headless *
* Optional — see "Setting it up as your own" below.
| Architectures | linux/amd64, linux/arm64 — native builds, no emulation at runtime |
| Channels | stable and experimental, tracked independently |
| Updates | Automatic, within hours of an upstream release |
| Base | debian:trixie-slim, runs as a non-root user |
| Supply chain | Checksum-verified downloads where upstream publishes sums, plus SBOM and signed build provenance |
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:
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.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.
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
| Tag | Tracks |
|---|---|
latest | newest stable release |
stable | newest stable release |
experimental | newest experimental release |
2, 2.0 | newest stable release in that series |
2.0.77 | one 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.
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.
| Variable | Default | Notes |
|---|---|---|
NAME | Factorio | Shown in the server browser |
DESCRIPTION | A Factorio server running in Docker | |
TAGS | — | Comma-separated |
MAX_PLAYERS | 0 | 0 = unlimited |
| Variable | Default | Notes |
|---|---|---|
VISIBILITY_PUBLIC | false | Requires FACTORIO_USERNAME + FACTORIO_TOKEN |
VISIBILITY_LAN | true | |
FACTORIO_USERNAME | — | From your factorio.com profile |
FACTORIO_TOKEN | — | From https://factorio.com/profile |
GAME_PASSWORD | — | Password to join |
REQUIRE_USER_VERIFICATION | true | Verify players against Factorio auth |
ADMINS | — | Comma-separated usernames |
WHITELIST | — | Comma-separated; enables whitelist mode when set |
| Variable | Default | Notes |
|---|---|---|
AUTO_PAUSE | true | Set false to keep the factory running while nobody is connected |
AUTO_PAUSE_WHEN_PLAYERS_CONNECT | false | |
ONLY_ADMINS_CAN_PAUSE_THE_GAME | true | |
AFK_AUTOKICK_INTERVAL | 0 | Minutes; 0 disables |
| Variable | Default | Notes |
|---|---|---|
SAVE_NAME | default | Name used when generating the first map |
GENERATE_NEW_SAVE | true | Create a map when the volume has none |
LOAD_LATEST_SAVE | true | Load newest save rather than SAVE_NAME |
AUTOSAVE_INTERVAL | 10 | Minutes |
AUTOSAVE_SLOTS | 5 | |
NON_BLOCKING_SAVING | false | Avoids a stutter on save; uses more RAM |
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.
| Variable | Default | Notes |
|---|---|---|
UPDATE_MODS | false | Check 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.
| Variable | Default | Notes |
|---|---|---|
PUID / PGID | 845 | Set to your host user so saves stay editable |
PORT | 34197 | Game port (UDP) |
RCON_PORT | 27015 | |
RCON_PASSWORD | generated | A random one is generated and logged if unset |
BIND | — | Bind to a specific interface |
EXTRA_ARGS | — | Passed straight to the server binary |
examples/docker-compose.pi.yml is a working starting point. The things that
actually matter:
:experimental for now — see the note above on arm64 availability.AUTOSAVE_INTERVAL and lower AUTOSAVE_SLOTS.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.
Here's the Factorio Stats dashboard, running publicly off this exact setup:
![]() | ![]() |
![]() |
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.
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.
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.
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.
# 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.
See CONTRIBUTING.md.
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.
Content type
Image
Digest
sha256:e6f1f3e61…
Size
202.9 MB
Last updated
12 days ago
docker pull aconti90/factorio-headless