README.md
Rama main · réplica de lectura del repositorio original.
# 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.
[](https://github.com/aconti90/factorio-headless/actions/workflows/ci.yml)
[](https://github.com/aconti90/factorio-headless/actions/workflows/release-watch.yml)
[](https://hub.docker.com/r/aconti90/factorio-headless)
[](https://hub.docker.com/r/aconti90/factorio-headless)
[](LICENSE)
```
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 |
---
## Setting it up as your own
```bash
git remote add origin git@github.com: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](https://www.factorio.com/blog/post/fff-446)
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
```bash
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:
```bash
cp examples/docker-compose.yml docker-compose.yml
cp examples/.env.example .env # set RCON_PASSWORD
docker compose up -d
```
## Tags
| 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.
## 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
| 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 |
### Visibility and access
| 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 |
### Gameplay
| 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 |
### Saves
| 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 |
### 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.
| 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.
### Container
| 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 |
## 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.
```bash
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](#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](https://factorio-stats.alexconti.com/public-dashboards/d2794e00cb1e4c0189af4db31f4c0869?from=now-3h&to=now&timezone=browser) 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.
## 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:
```bash
# 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:
```bash
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](https://forums.factorio.com/viewtopic.php?t=120853) to
poll `api/latest-releases` rather than the download endpoint, which is
rate-limited. This does that, four times a day.
## Building locally
```bash
# 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](CONTRIBUTING.md).
## License
MIT — see [LICENSE](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.