Post

Kubernetes Deployment Guide

Deploy FICSIT.monitor and a Satisfactory game server on Kubernetes. Full manifest walkthrough for namespaces, StatefulSets, services, Traefik ingress, and TLS.

Kubernetes Deployment Guide

Overview

This guide describes the production Kubernetes deployment powering FICSIT.monitor. It deploys two namespaces: satisfactory for the game server, and satisfactory-dashboard for the monitoring application stack.

This is an advanced guide targeting teams with Kubernetes experience. For a simpler setup, use the Docker deployment guide.


Prerequisites

A bare-metal or cloud Kubernetes cluster with:

  • MetalLB — bare-metal load balancer (for assigning external IPs)
  • Longhorn — distributed block storage (for persistent volumes)
  • Traefik — ingress controller
  • cert-manager — TLS certificate management (Let’s Encrypt)
  • Keel — automated image update deployment

Namespace Structure

1
2
# satisfactory namespace — game server
# satisfactory-dashboard namespace — monitoring app

Game Server Deployment (namespace: satisfactory)

StatefulSet

The game server runs as a StatefulSet with 1 replica:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: satisfactory
  namespace: satisfactory
  annotations:
    keel.sh/policy: force
    keel.sh/trigger: poll
    keel.sh/pollSchedule: "@every 6h"
spec:
  serviceName: satisfactory
  replicas: 1
  template:
    spec:
      containers:
        - name: satisfactory
          image: wolveix/satisfactory-server:latest
          env:
            - name: MAXPLAYERS
              value: "8"
            - name: PGID
              value: "1000"
            - name: PUID
              value: "1000"
            - name: STEAMBETA
              value: "false"
            - name: SKIPUPDATE
              value: "false"
            - name: AUTOSAVEINTERVAL
              value: "300"
          ports:
            - name: game-tcp
              containerPort: 7777
              protocol: TCP
            - name: game-udp
              containerPort: 7777
              protocol: UDP
            - name: reliable
              containerPort: 8888
              protocol: TCP
            - name: frm-http
              containerPort: 8080
              protocol: TCP
            - name: frm-ws
              containerPort: 8081
              protocol: TCP
          resources:
            requests:
              cpu: "2"
              memory: 8Gi
            limits:
              cpu: "4"
              memory: 16Gi
          volumeMounts:
            - name: gamedata
              mountPath: /config
  volumeClaimTemplates:
    - metadata:
        name: gamedata
      spec:
        accessModes:
          - ReadWriteOnce
        storageClassName: longhorn-gameserver
        resources:
          requests:
            storage: 75Gi

Services

satisfactory-tcp (LoadBalancer — external game + API access):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: v1
kind: Service
metadata:
  name: satisfactory-tcp
  namespace: satisfactory
  annotations:
    metallb.universe.tf/allow-shared-ip: "shared-external-ip"
spec:
  type: LoadBalancer
  loadBalancerIP: "YOUR_PUBLIC_IP"
  selector:
    app.kubernetes.io/name: satisfactory
  ports:
    - name: game-tcp
      protocol: TCP
      port: 7777
      targetPort: 7777
    - name: reliable
      protocol: TCP
      port: 8888
      targetPort: 8888

satisfactory-udp (LoadBalancer — game UDP traffic):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: v1
kind: Service
metadata:
  name: satisfactory-udp
  namespace: satisfactory
  annotations:
    metallb.universe.tf/allow-shared-ip: "shared-external-ip"
spec:
  type: LoadBalancer
  loadBalancerIP: "YOUR_PUBLIC_IP"
  selector:
    app.kubernetes.io/name: satisfactory
  ports:
    - name: game-udp
      protocol: UDP
      port: 7777
      targetPort: 7777

satisfactory-frm (ClusterIP — internal FRM access from dashboard):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Service
metadata:
  name: satisfactory-frm
  namespace: satisfactory
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: satisfactory
  ports:
    - name: frm-http
      protocol: TCP
      port: 8080
      targetPort: 8080
    - name: frm-ws
      protocol: TCP
      port: 8081
      targetPort: 8081

With Kubernetes, FRM (ports 8080/8081) is accessible internally via ClusterIP. You only need to open 8080/8081 on the host firewall if you want external FRM access.


Dashboard Stack (namespace: satisfactory-dashboard)

Components

ComponentTypeImagePurpose
PostgreSQLStatefulSetpostgres:15Time-series metrics database (with TimescaleDB)
RedisDeploymentredis:7Queue, cache, session storage
WebDeploymentocholoko888/satisfactory-dashboard:latestnginx + PHP-FPM (Laravel app)
ReverbDeployment(same)WebSocket server (Laravel Reverb)
HorizonDeployment(same)Queue worker (Laravel Horizon)
SchedulerDeployment(same)Cron runner (polling jobs)

Web Pod Init Container

The web pod runs an init container that migrates and seeds the database on startup:

1
2
3
4
initContainers:
  - name: init-migrate
    image: ocholoko888/satisfactory-dashboard:latest
    command: ["sh", "-c", "php artisan migrate --force && php artisan db:seed --class=ServerSeeder --force"]

Web Pod Resources

1
2
3
4
5
6
7
resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: "1"
    memory: 512Mi

Traefik Ingress with TLS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: dashboard-ingress
  namespace: satisfactory-dashboard
  annotations:
    cert-manager.io/cluster-issuer: cloudflare-clusterissuer
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
  tls:
    - hosts:
        - satisfactory-dashboard.yourdomain.com
      secretName: dashboard-tls
  rules:
    - host: satisfactory-dashboard.yourdomain.com
      http:
        paths:
          # WebSocket path for Reverb
          - path: /app
            pathType: Prefix
            backend:
              service:
                name: dashboard-reverb
                port:
                  number: 8080
          # Everything else to the web service
          - path: /
            pathType: Prefix
            backend:
              service:
                name: dashboard-web
                port:
                  number: 80

TimescaleDB Setup

PostgreSQL must have TimescaleDB enabled. The migrations create TimescaleDB hypertables automatically, but the extension must be pre-installed. Apply a PostgreSQL ConfigMap with:

1
shared_preload_libraries: timescaledb

Or use a TimescaleDB Docker image instead of plain postgres:15.


Auto-Update with Keel

Keel polls Docker Hub every 6 hours and automatically deploys new images when a newer latest tag is available:

1
2
3
4
5
annotations:
  keel.sh/policy: force
  keel.sh/trigger: poll
  keel.sh/pollSchedule: "@every 6h"
  keel.sh/approvals: "0"

This means deployments update automatically without manual intervention.


See Also

This post is licensed under CC BY 4.0 by the author.