How to Deploy a Unity Mirror or FishNet Dedicated Server (Step by Step)

A Mirror or FishNet dedicated server is a headless Linux build of your Unity project. Here is how to build it, containerize it, push it to a registry and start sessions from your matchmaker.

Andrew Walker
Head of Business Development at Gameye

To deploy a Mirror or FishNet dedicated server, build your Unity project for the Linux Dedicated Server target, put the build in a Docker container, and start one container per match from your matchmaker. Neither library needs a hosting plugin, because the server is a normal headless Linux program that listens on one UDP port.

This guide walks through each step, with code for both libraries.

The short answer

  1. Build the project for Dedicated Server on Linux. Unity defines UNITY_SERVER in that build and strips rendering.
  2. Make the server start listening on boot, on a fixed port.
  3. Copy the build into a Docker image based on Linux x86-64.
  4. Push the image to Docker Hub or GitHub Container Registry with a new tag for every build.
  5. When a match is ready, your matchmaker calls POST /session. Gameye returns an IP and port, and players connect straight to your server.

Mirror and FishNet server settings compared

These are the settings that matter on a dedicated server. The defaults come from each library’s current source.

SettingMirrorFishNet
Default transportKCP, over UDPTugboat, LiteNetLib over UDP
Default listen port77777770
Auto start on a headless buildNetworkManager Headless Start Mode, default Do Nothing. Set it to Auto Start ServerServerManager Start On Headless, on by default
Start the server in codeNetworkManager.singleton.StartServer()InstanceFinder.ServerManager.StartConnection()
Set the port in codePortTransport.Port on the transportTransport.SetPort(port) or ServerManager.StartConnection(port)
Network rateNetworkManager Send Rate, default 60TimeManager Tick Rate, default 30
Player capNetworkManager Max Connections, default 100Tugboat Maximum Clients, default 4095
Disconnect hookOverride NetworkManager.OnServerDisconnectServerManager.OnRemoteConnectionState

Lower the player cap to your real match size. The default is far higher than one match needs.

Build the Linux dedicated server

In Unity Hub, add the Linux Dedicated Server Build Support module to your editor. Then pick Linux Server in Build Profiles, or Dedicated Server with Linux in Build Settings on older versions, and build to Build/.

For CI, add a small editor script and call it from the command line:

using UnityEditor;

public static class BuildScript
{
    public static void BuildDedicatedServer()
    {
        BuildPipeline.BuildPlayer(new BuildPlayerOptions
        {
            scenes = new[] { "Assets/Scenes/Main.unity" },
            locationPathName = "Build/GameServer.x86_64",
            target = BuildTarget.StandaloneLinux64,
            subtarget = (int)StandaloneBuildSubtarget.Server,
        });
    }
}
"$UNITY" -batchmode -quit -nographics \
  -projectPath "$PWD" \
  -buildTarget Linux64 -standaloneBuildSubtarget Server \
  -executeMethod BuildScript.BuildDedicatedServer \
  -logFile -

Run the binary on a Linux machine with -batchmode -nographics before you containerize it, and fix any startup errors there first.

Start Mirror in server mode

On the NetworkManager, set Headless Start Mode to Auto Start Server and keep the KCP port at 7777. Mirror then calls StartServer() when the headless build loads.

Add a shutdown hook, so the container exits when the match empties and the session stops billing:

using Mirror;
using UnityEngine;

public class GameNetworkManager : NetworkManager
{
    public override void OnServerDisconnect(NetworkConnectionToClient conn)
    {
        base.OnServerDisconnect(conn);
#if UNITY_SERVER
        // Mirror removes the connection before this callback runs.
        if (NetworkServer.connections.Count == 0)
            Application.Quit(0);
#endif
    }
}

In a real game, add a short grace period so a brief disconnect does not end a match in progress.

Start FishNet in server mode

FishNet’s ServerManager has Start On Headless enabled by default. A Linux Dedicated Server build starts Tugboat on port 7770 as soon as the NetworkManager loads, so you need no start code.

The shutdown hook looks like this:

using FishNet;
using FishNet.Connection;
using FishNet.Transporting;
using UnityEngine;

public class ServerShutdown : MonoBehaviour
{
#if UNITY_SERVER
    private void Start()
    {
        InstanceFinder.ServerManager.OnRemoteConnectionState += OnRemoteConnectionState;
    }

    private void OnRemoteConnectionState(NetworkConnection conn, RemoteConnectionStateArgs args)
    {
        if (args.ConnectionState != RemoteConnectionState.Stopped)
            return;
        // FishNet raises this event before it removes the client from Clients.
        if (InstanceFinder.ServerManager.Clients.Count <= 1)
            Application.Quit(0);
    }
#endif
}

The check is <= 1 and not == 0. FishNet still counts the leaving client when it raises the event.

How the server finds its port

Gameye offers two network modes, and you choose one per application.

Gameye sets that variable in both modes, so read it only when you run in host mode. In bridge mode it holds the public port, not the one to bind. For Mirror in host mode:

public override void Start()
{
    string value = System.Environment.GetEnvironmentVariable("GAMEYE_PORT_UDP_7777");
    if (ushort.TryParse(value, out ushort port) && transport is PortTransport pt)
        pt.Port = port;
    base.Start(); // Headless Start Mode then starts the server
}

For FishNet in host mode, turn off Start On Headless and start the server yourself with InstanceFinder.ServerManager.StartConnection(port), using GAMEYE_PORT_UDP_7770.

Write the Dockerfile

A headless Unity server needs very little from the base image. This one runs as a non-root user and starts the server directly:

FROM ubuntu:22.04

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
 && rm -rf /var/lib/apt/lists/* \
 && useradd -m gameuser

WORKDIR /app
COPY --chown=gameuser:gameuser Build/ /app/
RUN chmod +x /app/GameServer.x86_64

USER gameuser

# Mirror KCP listens on 7777/udp. For FishNet Tugboat, use 7770/udp.
EXPOSE 7777/udp

ENTRYPOINT ["/app/GameServer.x86_64", "-batchmode", "-nographics"]

Gameye nodes run linux/amd64. On an Apple silicon Mac, build for that platform and test locally:

docker build --platform linux/amd64 -t <your-org>/<your-image>:build-001 .
docker run --rm -p 7777:7777/udp <your-org>/<your-image>:build-001

See create and push your Dockerfile for the full template and the port table.

Push the image to Docker Hub or GHCR

Use a new tag for every build, such as a build number or a commit SHA. Nodes cache images by tag, so pushing latest again does not deliver your new build.

Docker Hub. Push the image, add the user gameyedocker as a read-only collaborator on a private repository, and add the webhook URL Gameye gives you, so each new tag registers itself:

docker push <your-org>/<your-image>:build-001

GitHub Container Registry. The image owner must match your Gameye organization name, as in ghcr.io/<your-org>/<your-image>. Give the GitHub account gameyedocker Read access to the package. The package can stay private. The GHCR guide includes a GitHub Actions workflow that publishes a unique tag on every push.

Then create an application in the Admin Panel. Point it at the repository and declare the UDP port your server listens on.

Start sessions from your matchmaker with POST /session

When your matchmaker has a full lobby, it asks Gameye for a server. Only image and location are required. image is your application name. location is a region ID from GET /available-location/<your-application>.

curl -X POST https://api.production-gameye.gameye.net/session \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "<your-application>",
    "location": "<region-id>",
    "ttl": "1h",
    "env": { "MATCH_MODE": "ranked" },
    "labels": { "match": "1234" }
  }'

Trial accounts call api.sandbox-gameye.gameye.net instead. The 201 response carries the address:

{
  "id": "f7965a22-e327-4541-a30e-8262890d3a29",
  "host": "203.0.113.42",
  "ports": [{ "type": "udp", "container": 7777, "host": 49152 }]
}

Send host and ports[].host to your players. On the client, set Mirror’s NetworkManager.networkAddress and the transport port, or Tugboat’s client address and port, and connect. Players connect to the external port from the response, not to 7777 or 7770.

Set a ttl on every session as a safety net, and call DELETE /session/{id} when the match ends. The full field list is in the POST /session reference.

Test and scale

Start with a short ttl, such as "10m", while you test. Start a session, connect two editor clients to the returned address, then leave and check that the container exits.

If players cannot connect, check the port first. In bridge mode, the application’s port binding must match the port your transport listens on. In host mode, the server must bind the GAMEYE_PORT_* value.

Scaling needs no extra work in Unity. Each match is one session, and your matchmaker calls POST /session once per match. Sessions start in 0.5 seconds on average, and there is no Gameye SDK to add to your server. Compute costs $0.07 per vCPU-hour on demand and $0.035 reserved, with no egress fees.

For library-specific notes, see the Mirror integration and FishNet integration pages, or the getting started guide.

Summary

To try it with your own server, start a free trial and run your first Mirror or FishNet session, or talk to an engineer about your setup.