An open, community-agnostic, Docker-based Minecraft server infrastructure running the latest version of Minecraft (26.2) with Spigot for enhanced plugin support and performance. Highly configurable and customizable for any use case.
- Latest Minecraft Version: Running Minecraft 26.2 with Spigot
- Docker Containerized: Easy deployment and management
- Web Dashboard: Built-in Spring Boot web application for server management
- Automated Backups: Scheduled backups with automatic cleanup and size management
- Alert Notifications: Discord notifications for server events and admin alerts
- Configurable: Environment-based configuration
- Persistent Data: Server data persists across container restarts
- Easy Management: Simple scripts for starting and stopping the server
- RCON Support: Send commands to the server remotely via web interface
- Python Client: A dependency-free client library for the REST APIs (
clients/python/)
For Kubernetes deployments, you will also need:
- A Kubernetes cluster (v1.24+)
- Helm 3.x
Follow the Quick Start guide below for running the server on your local machine.
For secure home deployment with proper firewall configuration, network setup, and security best practices, see the Self-Hosting Guide.
The Self-Hosting Guide covers:
- Hardware and network requirements
- Router and port forwarding configuration
- Firewall setup (UFW, iptables, OPNsense, pfSense)
- DDoS protection and rate limiting
- Dynamic DNS configuration
- SSL certificate setup for public access
- Monitoring and maintenance
- Advanced security configurations
If you want to run OMCSI in the cloud for the lowest possible cost, the Hetzner single-node Terraform module provisions one server, self-manages a kubeadm Kubernetes cluster on it, and deploys the Helm chart — all under the $20/month target with no managed-control-plane fee, cloud LoadBalancer, or NAT gateway. See the Cost Analysis for how this compares to managed Kubernetes (LKE ~$109/mo, EKS ~$248/mo).
OMCSI ships with a Helm chart in helm/omcsi/ for deploying to any Kubernetes cluster (k3s, kind, EKS, GKE, etc.).
Prerequisites
- A running Kubernetes cluster (v1.24+)
- Helm 3.x
- Container images available on Docker Hub under
dmccoystephenson(the default). If using custom images, see Building and Pushing Images below
Quick Start
# Lint the chart
helm lint helm/omcsi --set secrets.rconPassword=x --set secrets.adminPassword=x
# Install (rconPassword and adminPassword are required)
# Images default to Docker Hub under 'dmccoystephenson'.
# See "Storage Classes" section below for cloud deployments.
helm install omcsi ./helm/omcsi --namespace omcsi --create-namespace \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpassword
# Or install with the optional agent-manager enabled
helm install omcsi ./helm/omcsi --namespace omcsi --create-namespace \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpassword \
--set agentManager.enabled=true \
--set secrets.agentDiscordBotToken=BOT_TOKEN \
--set secrets.agentDiscordChannelId=CHANNEL_ID \
--set secrets.agentAnthropicApiKey=API_KEY
# Enable agent-manager on an existing release
helm upgrade omcsi ./helm/omcsi --namespace omcsi \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpassword \
--set agentManager.enabled=true \
--set secrets.agentDiscordBotToken=BOT_TOKEN \
--set secrets.agentDiscordChannelId=CHANNEL_ID \
--set secrets.agentAnthropicApiKey=API_KEY
# Upgrade an existing release
helm upgrade omcsi ./helm/omcsi --namespace omcsi --reuse-values
# Uninstall
helm uninstall omcsi --namespace omcsiInstalling without cloning the repo
The chart is also published as an OCI artifact on every helm/omcsi/Chart.yaml version bump (see .github/workflows/helm-publish.yml), so it can be installed directly without a local checkout:
helm install omcsi oci://ghcr.io/dmccoystephenson/charts/omcsi --version 0.1.0 \
--namespace omcsi --create-namespace \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpasswordNote: GHCR packages are private on first publish. The package's visibility must be switched to public once (repo → Packages → omcsi → Package settings → Change visibility) before anonymous helm install/helm pull from the oci:// URL will work; until then, run helm registry login ghcr.io with a token that has read:packages.
The chart exposes most application-level sample.env variables through values.yaml. Some values are intentionally computed or fixed in the templates (e.g., internal service URLs are derived from Helm helpers, RCON port references are sourced from minecraftWrapper.internalService.rconPort, and DATA_STORAGE_PATH is hardcoded to match the PVC mount). Docker Compose-only variables like container names and host port mappings are excluded — Kubernetes manages those natively. See helm/omcsi/values.yaml for the full list of configurable values including image tags, replica counts, resource requests/limits, storage classes, service types, and feature flags.
Key design notes:
secrets.rconPasswordandsecrets.adminPasswordare required — the chart will refuse to install without them- World data and service data persist across pod restarts via
PersistentVolumeClaimresources - Sensitive values (passwords, tokens, API keys) are stored in a Kubernetes
Secret - Internal service discovery uses Kubernetes
ServiceDNS names (e.g.,omcsi-minecraft-wrapper,omcsi-alert-manager) - The Minecraft game port is exposed via a configurable Service (default
NodePort); RCON, BlueMap, and the wrapper API are on a separate internalClusterIPService - The nginx config is managed via a
ConfigMapand points to the webapp service automatically - The agent-manager is disabled by default and can be enabled via
agentManager.enabled=true - Pods sharing the mcserver PVC use pod affinity to prefer co-locating on the same node (recommended for
ReadWriteOncevolumes) - The backup-manager uses
tardirectly on the mounted/mcserverPVC — no Docker socket or Docker CLI required; scheduled backups are enabled by default
The default image repositories in values.yaml point to dmccoystephenson/open-mc-server-* on Docker Hub. If those images are already published, no additional setup is needed.
To use custom images (e.g., a private fork or registry), build, tag, push, and override:
1. Build all images
docker build -t open-mc-server .
docker build -t open-mc-server-webapp ./web-app
docker build -t open-mc-server-nginx ./nginx
docker build -t open-mc-server-backup-manager ./backup-manager
docker build -t open-mc-server-alert-manager ./alert-manager
docker build -t open-mc-server-agent-manager ./agent-managerNote: The
open-mc-serverimage builds Spigot from source, which can take 10–15 minutes on the first run.
2. Tag and push to your registry
Replace YOUR_REGISTRY with your Docker Hub username, ECR URI, or other registry:
REGISTRY=YOUR_REGISTRY
for img in open-mc-server open-mc-server-webapp open-mc-server-nginx \
open-mc-server-backup-manager open-mc-server-alert-manager \
open-mc-server-agent-manager; do
docker tag "$img:latest" "$REGISTRY/$img:latest"
docker push "$REGISTRY/$img:latest"
done3. Override image repositories at install time
REGISTRY=YOUR_REGISTRY
helm install omcsi ./helm/omcsi --namespace omcsi --create-namespace \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpassword \
--set minecraftWrapper.image.repository=$REGISTRY/open-mc-server \
--set webapp.image.repository=$REGISTRY/open-mc-server-webapp \
--set nginx.image.repository=$REGISTRY/open-mc-server-nginx \
--set backupManager.image.repository=$REGISTRY/open-mc-server-backup-manager \
--set alertManager.image.repository=$REGISTRY/open-mc-server-alert-managerWhen using Terraform, set the image_registry variable instead:
image_registry = "YOUR_REGISTRY"The Helm chart defaults all PVC storageClass values to an empty string, which uses the cluster's default StorageClass. If your cluster does not have a default StorageClass configured, PVCs will remain in Pending state and pods will fail to schedule.
Common StorageClass values by provider:
| Provider | StorageClass | Notes |
|---|---|---|
| AWS EKS | gp2 or gp3 |
EBS volumes; gp2 is the most common default |
| Linode LKE | linode-block-storage-retain |
Linode Block Storage |
| Minikube | standard |
Pre-configured as default |
| GKE | standard or premium-rwo |
Usually configured as default |
Override at install time:
helm install omcsi ./helm/omcsi --namespace omcsi --create-namespace \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpassword \
--set persistence.mcserver.storageClass=gp2 \
--set persistence.webappData.storageClass=gp2 \
--set persistence.alertManagerData.storageClass=gp2 \
--set persistence.backups.storageClass=gp2When using Terraform, the storage_class variable is set automatically (defaults to gp2 on AWS, linode-block-storage-retain on Linode).
By default, all four PVCs (mcserver, webappData, alertManagerData, backups) use ReadWriteOnce (RWO). An RWO volume can only be mounted read-write by pods on a single node at a time, so the chart uses pod affinity (preferredDuringSchedulingIgnoredDuringExecution) to encourage minecraft-wrapper, webapp, and backup-manager to co-locate on the same node as the mcserver PVC. This is a preference, not a requirement — on a resource-constrained multi-node cluster, the scheduler can still place these pods on different nodes, which will leave the non-co-located pod's volume mount stuck.
If you run a multi-node cluster and want to remove this constraint entirely, switch the relevant PVC(s) to ReadWriteMany (RWX) with a StorageClass that supports it:
helm install omcsi ./helm/omcsi --namespace omcsi --create-namespace \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpassword \
--set persistence.mcserver.accessMode=ReadWriteMany \
--set persistence.mcserver.storageClass=nfs-clientRWX-capable StorageClass options:
| Option | Notes |
|---|---|
NFS (e.g. nfs-subdir-external-provisioner) |
Simplest self-hosted option; works on any cluster with an NFS server reachable from all nodes |
AWS EFS (via the aws-efs-csi-driver) |
Managed RWX storage on EKS; requires the EFS CSI driver add-on and an EFS file system |
| Longhorn | Self-hosted distributed block storage with RWX support; works on any cluster (bare metal, Hetzner, etc.) |
This is a Kubernetes-only concern — Docker Compose runs all services on a single host, so there is no equivalent scheduling constraint to document there.
RWX also unblocks horizontal autoscaling for webapp and alert-manager (see webapp.autoscaling / alertManager.autoscaling in values.yaml): their Deployment templates fail if autoscaling is enabled with more than 1 replica while their PVC(s) are still ReadWriteOnce.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Pods stuck in ImagePullBackOff |
Images not pushed to a registry the cluster can access | Build and push images to your container registry; set image repository overrides via --set or image_registry in Terraform (see Building and Pushing Images) |
PVC stuck in Pending / "unbound immediate PersistentVolumeClaims" |
No default StorageClass on the cluster | Set persistence.*.storageClass to a valid StorageClass for your provider (see Storage Classes) |
VolumeBinding: context deadline exceeded |
EBS CSI driver add-on not installed (required on EKS 1.23+) | The AWS Terraform module installs the EBS CSI driver automatically; if deploying manually, install the aws-ebs-csi-driver EKS add-on |
Pods stuck in Pending (resources) |
Insufficient CPU/memory on cluster nodes | Scale up worker nodes or reduce resource requests in values.yaml |
Pods CrashLoopBackOff |
Application startup failure | Check logs with kubectl logs -n omcsi <pod-name> |
Use kubectl port-forward to access OMCSI services on localhost without exposing them via a LoadBalancer or NodePort. This is useful for quick local testing or when your cluster doesn't have external access configured.
# Minecraft server – forward localhost:25565 to the minecraft-wrapper service
kubectl port-forward svc/omcsi-minecraft-wrapper -n omcsi 25565:25565
# Connect your Minecraft client to localhost:25565
# Web dashboard via nginx – forward localhost:8443 to the nginx HTTPS port
kubectl port-forward svc/omcsi-nginx -n omcsi 8443:443
# Open https://localhost:8443 in your browser
# Run port-forwards in the background by appending &
kubectl port-forward svc/omcsi-minecraft-wrapper -n omcsi 25565:25565 &
kubectl port-forward svc/omcsi-nginx -n omcsi 8443:443 &Tip: If port 25565 is already in use on your machine, pick a different local port:
kubectl port-forward svc/omcsi-minecraft-wrapper -n omcsi 25566:25565and connect tolocalhost:25566.
Minikube provides a single-node Kubernetes cluster on your local machine, ideal for testing the Helm chart before deploying to a production cluster.
1. Install prerequisites
2. Start minikube
# Start a minikube cluster with enough resources for OMCSI
minikube start --cpus=4 --memory=8192 --driver=docker3. Build images inside minikube
Minikube runs its own Docker daemon. To make locally built images available without a registry, point your shell's Docker client at minikube's daemon:
# Configure your shell to use minikube's Docker daemon
eval $(minikube docker-env)
# Build all OMCSI images (these will be available inside the cluster)
docker build -t open-mc-server .
docker build -t open-mc-server-webapp ./web-app
docker build -t open-mc-server-nginx ./nginx
docker build -t open-mc-server-backup-manager ./backup-manager
docker build -t open-mc-server-alert-manager ./alert-manager
docker build -t open-mc-server-agent-manager ./agent-managerNote: The
open-mc-serverimage builds Spigot from source, which can take 10–15 minutes on the first run.
4. Install the Helm chart
# Lint the chart first
helm lint helm/omcsi --set secrets.rconPassword=test --set secrets.adminPassword=test
# Install with imagePullPolicy=Never so Kubernetes uses the local images
helm install omcsi ./helm/omcsi --namespace omcsi --create-namespace \
--set secrets.rconPassword=changeme \
--set secrets.adminPassword=strongpassword \
--set minecraftWrapper.image.pullPolicy=Never \
--set webapp.image.pullPolicy=Never \
--set nginx.image.pullPolicy=Never \
--set backupManager.image.pullPolicy=Never \
--set alertManager.image.pullPolicy=Never5. Verify the deployment
# Watch pods come up
kubectl get pods -n omcsi -w
# Check all services
kubectl get svc -n omcsi
# View logs for a specific service
kubectl logs -n omcsi -l app.kubernetes.io/component=minecraft-wrapper -f
# Check PVCs are bound
kubectl get pvc -n omcsi6. Access services
# Minecraft game port – get the NodePort URL
minikube service omcsi-minecraft-wrapper -n omcsi --url
# Web dashboard via nginx – get the LoadBalancer URL
# (minikube tunnel is required for LoadBalancer services)
minikube tunnel &
kubectl get svc -n omcsi omcsi-nginx
# Connect to the EXTERNAL-IP shown for the nginx service
# Alternatively, use port-forwarding for quick access
kubectl port-forward svc/omcsi-minecraft-wrapper -n omcsi 25565:25565 &
# Connect your Minecraft client to localhost:25565
kubectl port-forward svc/omcsi-nginx -n omcsi 8443:443 &
# Dashboard at https://localhost:84437. Test persistence
# Verify world data survives a pod restart
kubectl delete pod -n omcsi -l app.kubernetes.io/component=minecraft-wrapper
# Wait for the pod to restart, then check that the world data is still present
kubectl get pods -n omcsi -w8. Clean up
# Uninstall the release
helm uninstall omcsi --namespace omcsi
# Delete PVCs (optional – removes all persistent data)
kubectl delete pvc --all -n omcsi
# Delete the namespace
kubectl delete namespace omcsi
# Stop minikube
minikube stop
# (Optional) Delete the minikube cluster entirely
minikube deleteTroubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Pods stuck in ImagePullBackOff |
Images not built in minikube's Docker | Re-run eval $(minikube docker-env) and rebuild images; ensure imagePullPolicy is Never or IfNotPresent |
Pods stuck in Pending |
Insufficient CPU/memory in minikube | Restart minikube with more resources: minikube start --cpus=4 --memory=8192 |
PVC stuck in Pending |
No default storage class | Minikube ships with a default StorageClass; verify with kubectl get sc |
| Cannot reach services | Minikube tunnel not running | Run minikube tunnel for LoadBalancer services, or use minikube service <name> -n omcsi for NodePort |
Pods CrashLoopBackOff |
Application startup failure | Check logs with kubectl logs -n omcsi <pod-name> |
The terraform/hetzner/ directory contains Terraform configuration that provisions a single Hetzner Cloud server, bootstraps a self-managed single-node Kubernetes cluster on it with kubeadm, and deploys the OMCSI Helm chart — in one terraform apply. It is the lowest-cost cloud option (~$14/month on the default cax31) because it carries none of the managed-Kubernetes charges:
- No control-plane fee — the control plane runs on the same node (untainted so workloads schedule on it)
- No cloud LoadBalancer — services are exposed via fixed NodePorts (25565/80/443) on the node's public IP (the API server's
--service-node-port-rangeis widened to80-32767) - No NAT gateway — the node has a public IP directly
- No separate block storage — the Rancher local-path provisioner backs PVCs with the node's included NVMe
The cluster uses containerd, Calico (so the chart's NetworkPolicies are enforced), and Helm. See Cost Analysis for the full comparison.
Trade-off: You self-manage the cluster (upgrades, etcd backups, node maintenance) and there is no HA — this is the explicit deal for the lower price. The setup maps directly onto CKA-style skills (kubeadm, CNI, taints, NodePort ranges, StorageClasses).
Prerequisites
- Terraform >= 1.3
- A Hetzner Cloud API token (Read & Write)
- An SSH key pair (Terraform bootstraps and deploys over SSH)
Quick Start
cd terraform/hetzner
# Copy the example tfvars and fill in your values
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars – set hcloud_token, ssh_public_key, ssh_private_key_path,
# rcon_password, and admin_password at minimum
terraform init
terraform plan
terraform applyterraform apply prints the connection details when it finishes:
terraform output minecraft_address # e.g. 203.0.113.10:25565 – connect your client here
terraform output dashboard_url # https://203.0.113.10 – self-signed cert (expect a warning)
terraform output kubectl_hint # export KUBECONFIG=... && kubectl get pods -n omcsiFirst-boot time: The server spends several minutes on first boot bootstrapping Kubernetes (cloud-init) before Helm deploys. Terraform waits for this automatically. Watch progress with
ssh -i <key> root@<ip> 'tail -f /var/log/omcsi-bootstrap.log'.
Key Variables
| Variable | Description | Default |
|---|---|---|
hcloud_token |
Hetzner Cloud API token | (required) |
ssh_public_key |
SSH public key contents added to the server | (required) |
ssh_private_key_path |
Path to the matching private key (used for provisioning) | (required) |
rcon_password / admin_password |
OMCSI secrets | (required) |
server_type |
Hetzner server type | cax31 (ARM, 16 GB) |
location |
Hetzner location (CAX/ARM is EU-only: fsn1/nbg1/hel1) |
fsn1 |
allowed_ssh_cidr |
CIDR allowed to reach SSH (22), and the K8s API too unless allowed_api_cidrs is set |
0.0.0.0/0 (restrict this) |
allowed_api_cidrs |
CIDRs allowed to reach the K8s API (6443); empty falls back to allowed_ssh_cidr |
[] |
kubernetes_version |
Kubernetes minor version | 1.34 |
java_opts |
Minecraft JVM heap (sized for 16 GB) | -Xmx6G -Xms4G |
whitelist_enabled |
Only players in whitelist.json may join |
false |
enforce_whitelist |
Also kick connected players who are not whitelisted when the list reloads | false |
image_registry |
Container image registry prefix | dmccoystephenson |
image_tag |
Tag for the Minecraft server image — pin it for reproducible deploys | latest |
supporting_image_tag |
Tag for the five supporting images — a commit SHA pins them (see below) | latest |
difficulty |
World difficulty (peaceful/easy/normal/hard) |
normal |
gamemode |
Default gamemode (survival/creative/adventure/spectator) |
survival |
pvp_enabled |
Allow players to damage each other | true |
online_mode |
Verify players against Mojang auth | true |
default_plugins |
Comma-separated plugin JAR download URLs, installed on setup | "" |
rbac_enabled |
Create the namespace-scoped omcsi-viewer ServiceAccount |
false |
rbac_operator_enabled |
Also create omcsi-operator (namespace-admin; needs rbac_enabled) |
false |
See terraform/hetzner/variables.tf for the full list (operator identity, MOTD, Discord, agent-manager, NodePort overrides, etc.).
Pin
image_tagfor anything long-lived. It defaults tolatest, and the chart pulls withimagePullPolicy: Always. Because the Spigot jar is compiled into the image at build time, a movinglatestmoves the Minecraft version along with it. Setimage_tagto a published tag — and keep it consistent withminecraft_version, since the entrypoint selects the jar by that name and exits if the image does not contain it.
The two tag variables are pinned differently.
.github/workflows/docker-publish.ymlpublishesopen-mc-serveraslatest, the Minecraft version it was built with, and the commit SHA. It publishes the five supporting images (webapp,nginx,backup-manager,alert-manager,agent-manager) aslatestand the commit SHA — they have no version to be tagged with, which is why the tag is split across two variables.So pin
image_tagto a Minecraft version (keeping it equal tominecraft_version), and pinsupporting_image_tagto a commit SHA:image_tag = "26.2" supporting_image_tag = "c40d2b7f1e8a94c3b2d5e6f7a8b9c0d1e2f3a4b5"A SHA tag only exists for commits that rebuilt the images. The publish workflow is path-filtered — it runs when the Dockerfile, a service directory,
resources/, or the workflow itself changes — so a commit that only touched documentation has no images and no tag. Pick a SHA from a run of that workflow, not an arbitrary commit;docker manifest inspect dmccoystephenson/open-mc-server-webapp:<sha>confirms one exists before you deploy it.Leaving both at
latestis supported and remains the default. It means the running code is whatever was last published, which is fine for a server you rebuild often and wrong for one you want to be able to roll back.
Granting kubectl without granting SSH. These were one setting, so allowing a monitoring host to reach the Kubernetes API also gave it a shell on the node.
allowed_api_cidrsseparates them, and being a list it can name more than one operator — which a single CIDR cannot:allowed_ssh_cidr = "203.0.113.4/32" # you, with a shell allowed_api_cidrs = ["203.0.113.4/32", "198.51.100.7/32"] # you and a monitorLeaving
allowed_api_cidrsempty keeps the old behaviour exactly: the API falls back toallowed_ssh_cidr. Note the fallback is a fallback, not a union — once you setallowed_api_cidrs, it is the whole list, so include your own address in it or you will lock yourself out of the API.
default_pluginsformat: a comma-separated list of direct download URLs to plugin JARs, with no spaces — e.g.default_plugins = "https://example.com/A.jar,https://example.com/B.jar". Each is downloaded into the plugins directory on server setup; a plugin already present there (matched by filename) is left untouched.
ARM images:
cax31is ARM64. The defaultdmccoystephensonimages are published multi-arch (amd64 + arm64), so they run as-is. For an x86 server, setserver_type = "cpx31"andlocationto any region.
Tear Down
terraform destroyThis removes the server, firewall, and SSH key. All world data on the node is destroyed — back up first (the backup-manager writes to a PVC on the node; copy it off before destroying).
The terraform/linode/ directory contains Terraform configuration to provision a Linode Kubernetes Engine (LKE) cluster and deploy the OMCSI Helm chart in a single terraform apply.
Prerequisites
- Terraform >= 1.3
- A Linode API token with read/write access to Kubernetes
Quick Start
cd terraform/linode
# Copy the example tfvars and fill in your values
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars – set linode_token, rcon_password, admin_password at minimum
terraform init
terraform plan
terraform applyAfter apply completes, a kubeconfig.yaml is written to the terraform/linode/ directory. Use it to interact with the cluster:
export KUBECONFIG=$(pwd)/kubeconfig.yaml
kubectl get pods -n omcsiVariables
| Variable | Description | Default |
|---|---|---|
linode_token |
Linode API personal access token | (required) |
cluster_label |
Label for the LKE cluster | omcsi |
region |
Linode region | us-east |
k8s_version |
Kubernetes version | 1.34 |
node_type |
Linode instance type for workers | g6-standard-4 |
node_count |
Number of worker nodes | 2 |
rcon_password |
RCON password for Minecraft server | (required) |
admin_password |
Admin password for web dashboard | (required) |
image_registry |
Container image registry prefix (e.g., your-dockerhub-user) |
dmccoystephenson |
storage_class |
Kubernetes StorageClass for PVCs | linode-block-storage-retain |
node_pool_autoscaler |
Autoscaler config object (enabled, min, max) |
{enabled=false, min=2, max=4} |
minecraft_service_type |
Kubernetes Service type for the Minecraft port (NodePort, LoadBalancer, or ClusterIP) |
NodePort |
nginx_service_type |
Kubernetes Service type for nginx (LoadBalancer or ClusterIP when using Traefik) |
LoadBalancer |
enable_traefik |
Deploy Traefik as a single-IP ingress for Minecraft, HTTP, and HTTPS | false |
discord_webhook_url |
Discord webhook URL for alert-manager (auto-enables Discord when set) | "" |
deploy_auth_token |
Bearer token for the plugin hot-deploy endpoint | "" |
agent_manager_enabled |
Enable the Discord AI bot | false |
helm_values_file |
Path to additional Helm values file | "" |
See terraform/linode/variables.tf for the full list including agent-manager options.
Note: Images default to Docker Hub under
dmccoystephenson. Overrideimage_registryif using a custom registry (see Building and Pushing Images).
Tear Down
terraform destroyThis removes the LKE cluster, all Kubernetes resources, and associated Linode infrastructure.
The terraform/aws/ directory contains Terraform configuration to provision an Amazon EKS cluster (with VPC, subnets, and managed node group) and deploy the OMCSI Helm chart.
Prerequisites
- Terraform >= 1.3
- AWS CLI v2 configured (
aws configure) - An AWS account with permissions to create EKS clusters, VPCs, IAM roles, and EC2 instances
Quick Start
cd terraform/aws
# Copy the example tfvars and fill in your values
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars – set rcon_password, admin_password at minimum
terraform init
terraform plan
terraform applyAfter apply completes, configure kubectl to talk to the new cluster:
aws eks update-kubeconfig --region us-east-1 --name omcsi
kubectl get pods -n omcsiVariables
| Variable | Description | Default |
|---|---|---|
aws_region |
AWS region for the EKS cluster | us-east-1 |
cluster_name |
Name for the EKS cluster | omcsi |
cluster_version |
Kubernetes version | 1.34 |
node_instance_type |
EC2 instance type for workers | t3.large |
node_desired_count |
Desired number of worker nodes | 2 |
node_min_count |
Minimum workers (autoscaling) | 1 |
node_max_count |
Maximum workers (autoscaling) | 4 |
rcon_password |
RCON password for Minecraft server | (required) |
admin_password |
Admin password for web dashboard | (required) |
image_registry |
Container image registry prefix (e.g., your-dockerhub-user) |
dmccoystephenson |
storage_class |
Kubernetes StorageClass for PVCs | gp2 |
minecraft_service_type |
Kubernetes Service type for the Minecraft port (NodePort, LoadBalancer, or ClusterIP) |
NodePort |
nginx_service_type |
Kubernetes Service type for nginx (LoadBalancer or ClusterIP when using Traefik) |
LoadBalancer |
enable_traefik |
Deploy Traefik as a single-IP ingress for Minecraft, HTTP, and HTTPS | false |
discord_webhook_url |
Discord webhook URL for alert-manager (auto-enables Discord when set) | "" |
deploy_auth_token |
Bearer token for the plugin hot-deploy endpoint | "" |
agent_manager_enabled |
Enable the Discord AI bot | false |
helm_values_file |
Path to additional Helm values file | "" |
See terraform/aws/variables.tf for the full list including agent-manager options.
Note: Images default to Docker Hub under
dmccoystephenson. Overrideimage_registryif using a custom registry (see Building and Pushing Images).
Tear Down
terraform destroyThis removes the EKS cluster, node group, VPC, IAM roles, and all Kubernetes resources.
The terraform/existing-cluster/ directory contains Terraform configuration to deploy the OMCSI Helm chart to any existing Kubernetes cluster — no cluster provisioning required. This is useful when you already have a cluster (e.g., k3s, minikube, GKE, on-premise) and just want to deploy the application.
Prerequisites
- Terraform >= 1.3
- A kubeconfig file with access to the target cluster (default:
~/.kube/config)
Quick Start
cd terraform/existing-cluster
# Copy the example tfvars and fill in your values
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars – set rcon_password, admin_password at minimum
terraform init
terraform plan
terraform applyBy default the module uses ~/.kube/config with the current context. To target a specific kubeconfig or context:
terraform apply \
-var kubeconfig_path="/path/to/kubeconfig.yaml" \
-var kubeconfig_context="my-cluster-context" \
-var rcon_password=changeme \
-var admin_password=strongpassAfter apply completes, verify the deployment:
kubectl get pods -n omcsiVariables
| Variable | Description | Default |
|---|---|---|
kubeconfig_path |
Path to the kubeconfig file | ~/.kube/config |
kubeconfig_context |
Context to use (empty = current-context) | "" |
rcon_password |
RCON password for Minecraft server | (required) |
admin_password |
Admin password for web dashboard | (required) |
image_registry |
Container image registry prefix (e.g., your-dockerhub-user) |
dmccoystephenson |
storage_class |
Kubernetes StorageClass for PVCs (empty = cluster default) | "" |
minecraft_service_type |
Kubernetes Service type for the Minecraft port (NodePort, LoadBalancer, or ClusterIP) |
NodePort |
nginx_service_type |
Kubernetes Service type for nginx (LoadBalancer or ClusterIP when using Traefik) |
LoadBalancer |
enable_traefik |
Deploy Traefik as a single-IP ingress for Minecraft, HTTP, and HTTPS | false |
discord_webhook_url |
Discord webhook URL for alert-manager (auto-enables Discord when set) | "" |
deploy_auth_token |
Bearer token for the plugin hot-deploy endpoint | "" |
agent_manager_enabled |
Enable the Discord AI bot | false |
helm_values_file |
Path to additional Helm values file | "" |
See terraform/existing-cluster/variables.tf for the full list including agent-manager options.
Note: The
storage_classvariable defaults to empty (cluster default). If your cluster doesn't have a default StorageClass, set it explicitly (e.g.,local-path,gp2,standard).
Tear Down
terraform destroyThis removes only the OMCSI Helm release and namespace — the cluster itself is not affected.
-
Clone the repository
git clone <your-repo-url> cd open-mc-server-infrastructure
-
Configure the server
cp sample.env .env # Edit .env with your settings (see Configuration section) -
Start the server
chmod +x up.sh down.sh ./up.sh
Note: The first build will take 10-15 minutes as it downloads and compiles Spigot from source. The JARs for all services will be built automatically during the Docker build process.
-
Connect to your server
- Server address:
localhost:25565(or your server's IP) - Web Dashboard:
https://localhost:8443(or your server's IP with port 8443) - The server will take a few minutes to build on first run
- Note: You'll see a security warning for the self-signed certificate. This is expected for development. See the Security section for production setup.
- Server address:
The server includes a built-in web dashboard that provides:
- Server Status: Real-time view of server status, player count, and MOTD
- Admin Console: Send commands to the server using RCON
- External Links: Quick access to Dynmap, BlueMap, Accordion Chat, and other services
- Activity Tracker Integration: View player statistics and leaderboards (optional)
- Secure Access: HTTPS encryption with reverse proxy to protect credentials
Access the dashboard at https://localhost:8443 (or your configured WEB_HTTPS_PORT). HTTP requests to port 8080 (or WEB_HTTP_PORT) will automatically redirect to HTTPS.
The server uses self-signed SSL certificates by default for development. When you first access the web dashboard, your browser will show a security warning. This is expected and safe for local development.
For production use, replace the self-signed certificates with certificates from a trusted Certificate Authority. Certificates live in the nginx-ssl named Docker volume (mounted at /etc/nginx/ssl inside the nginx container), not a host directory, so copy them in with docker cp after the stack is running:
- Obtain SSL certificates (e.g., from Let's Encrypt)
- Start the stack so the
nginx-sslvolume exists:./up.sh - Copy the certificate and key into the running container:
docker cp fullchain.pem open-mc-nginx:/etc/nginx/ssl/cert.pem docker cp privkey.pem open-mc-nginx:/etc/nginx/ssl/key.pem
- Restart nginx to pick up the new certificate:
docker compose restart nginx
Alternatively, you can generate new self-signed certificates locally and copy them in the same way:
./scripts/generate-ssl-certs.sh
docker cp nginx/ssl/cert.pem open-mc-nginx:/etc/nginx/ssl/cert.pem
docker cp nginx/ssl/key.pem open-mc-nginx:/etc/nginx/ssl/key.pem
docker compose restart nginxThe web dashboard can optionally integrate with the Activity Tracker plugin to display player statistics and leaderboards. When enabled, the dashboard will show:
- Server Statistics: Number of unique players and total logins
- Player Leaderboard: Top 10 players ranked by hours played, with total logins
To enable Activity Tracker integration:
- Install the Activity Tracker plugin on your Minecraft server
- Configure the plugin to enable its REST API (see plugin documentation)
- Set the following environment variables in your
.envfile:ACTIVITY_TRACKER_ENABLED=true ACTIVITY_TRACKER_URL=http://localhost:8080
- Restart the web application with
./up.sh
The Activity Tracker data will automatically refresh with the server status updates. If the Activity Tracker API is not available, the sections will be hidden without affecting other dashboard functionality.
The infrastructure supports integration with Accordion Chat, a real-time web-based chat application. When configured, players and administrators can communicate through a modern web interface accessible from the dashboard.
Accordion Chat runs as a separate application to avoid duplication and ensure updates can be made to Accordion independently of the infrastructure project.
To enable Accordion Chat integration:
-
Initialize and run the Accordion Chat submodule:
# Initialize the accordion-chat submodule git submodule update --init accordion-chat cd accordion-chat # Follow the Accordion setup instructions in its README docker compose up -d
-
Configure the web dashboard to link to your running Accordion instance by setting the following in your
.envfile:ACCORDION_CHAT_URL=http://localhost:3000
For accessing from other machines on your network, use your server's IP address:
ACCORDION_CHAT_URL=http://192.168.1.100:3000
-
Restart the infrastructure services to apply the configuration:
./up.sh
Once configured, a "Chat" link will appear in the web dashboard's External Services section pointing to your Accordion Chat instance.
Note: For production use with persistent storage and other configuration options, refer to the Accordion Chat documentation.
Copy sample.env to .env and modify the following settings:
OPERATOR_UUID: Your Minecraft player UUID (get from mcuuid.net)OPERATOR_NAME: Your Minecraft usernameSERVER_MOTD: Message displayed in the server listMAX_PLAYERS: Maximum number of players allowed
Note: If OPERATOR_UUID and OPERATOR_NAME are not properly configured, the server will still start but you'll need to manually add operators using the op <username> command in the server console.
DIFFICULTY: Server difficulty (peaceful, easy, normal, hard)GAMEMODE: Default game mode (survival, creative, adventure, spectator)PVP_ENABLED: Enable/disable player vs player combatONLINE_MODE: Enable Mojang authentication (set to false for offline/cracked servers)WHITELIST_ENABLED: Turn the whitelist on so only players listed inwhitelist.jsonmay join (default:false)ENFORCE_WHITELIST: Also kick already-connected players who are not on the whitelist whenever the list is reloaded. No effect unlessWHITELIST_ENABLEDistrue(default:false)DEFAULT_PLUGINS: Comma-separated list of direct download URLs to plugin JARs, installed automatically intoPLUGINS_DIRECTORYon server setup. A plugin already present there (matched by filename) is left untouched, so it's safe to leave alongside manually or CI-deployed plugins. Leave empty to skip (default).
To run a private or invite-only server, set both of these and restart:
WHITELIST_ENABLED=true
ENFORCE_WHITELIST=trueThey map to white-list and enforce-whitelist in server.properties. They are
kept separate because they do different things: WHITELIST_ENABLED blocks new
logins from players who are not on the list, while ENFORCE_WHITELIST
additionally kicks players who are already connected and not on the list the
next time the list is reloaded (for example after /whitelist reload). Leaving
enforcement off is the usual choice while curating the list on a live server.
On Kubernetes the equivalent chart values are WHITELIST_ENABLED and
ENFORCE_WHITELIST under minecraftWrapper.env in helm/omcsi/values.yaml; the
Hetzner Terraform module exposes them as whitelist_enabled and
enforce_whitelist.
The list itself is managed the usual way — /whitelist add <player> in the
console, or by editing whitelist.json. whitelist.json lives on the persistent
volume and survives restarts.
Why an environment variable and not
/whitelist on: the container regeneratesserver.propertiesfrom these settings on every start (see below), so/whitelist ontyped in the console is silently reverted the next time the server restarts.
resources/post-create.sh rewrites /mcserver/server.properties in full on
every container start, not just on first setup. Any hand edit to that file —
made with an editor, or by a console command that persists to it — is lost on the
next restart. Configure the server through the environment variables above
instead; settings that OMCSI does not expose fall back to the server's own
defaults and are likewise reset each start.
Data files next to it on the persistent volume — whitelist.json, ops.json,
banned-players.json, and the world — are not touched by this and persist
normally.
These settings allow you to run multiple server instances in parallel without conflicts:
CONTAINER_NAME: Docker container name (default:open-mc-server)HOST_PORT: Host port for Minecraft server (default:25565)HOST_RCON_PORT: Host port for RCON (default:25575)HOST_BLUEMAP_PORT: Host port for BlueMap (default:8100)WRAPPER_PORT: Host port for the minecraft-wrapper REST API (default:8092)VOLUME_NAME: Docker volume name for persistent data (default:mcserver)
To run multiple servers simultaneously (e.g., for testing different configurations), create separate .env files with unique values for CONTAINER_NAME, HOST_PORT, HOST_RCON_PORT, HOST_BLUEMAP_PORT, WRAPPER_PORT, VOLUME_NAME, WEB_CONTAINER_NAME, NGINX_CONTAINER_NAME, BACKUP_CONTAINER_NAME, ALERT_CONTAINER_NAME, BACKUP_PORT, ALERT_PORT, AGENT_PORT, WEB_HTTP_PORT, and WEB_HTTPS_PORT, then start each with:
docker compose --env-file .env.dev2 up -d --buildWEB_CONTAINER_NAME: Web application container name (default:open-mc-webapp)NGINX_CONTAINER_NAME: Nginx reverse proxy container name (default:open-mc-nginx)WEB_HTTP_PORT: HTTP port (redirects to HTTPS, default:8080)WEB_HTTPS_PORT: HTTPS port (default:8443)RCON_PASSWORD: Password for RCON authentication (default:minecraft)ADMIN_USERNAME: Username for admin console authentication (default:admin)ADMIN_PASSWORD: Password for admin console authentication (default:admin)DYNMAP_URL: URL to Dynmap web interface (optional)BLUEMAP_URL: URL to BlueMap web interface (optional)ACCORDION_CHAT_URL: URL to Accordion Chat web interface (optional, e.g.,http://localhost:3000). Accordion runs separately - see Accordion Chat Integration section.ACTIVITY_TRACKER_URL: URL to Activity Tracker plugin REST API (optional, e.g.,http://localhost:8080)ACTIVITY_TRACKER_ENABLED: Enable Activity Tracker integration (default:false)
Note: The RCON password must match between the server and web application for admin commands to work. Change the admin username and password from defaults in production for security. All connections to the web dashboard are encrypted using HTTPS to protect your credentials.
The BlueMap plugin serves its own webapp from inside the wrapper container on port 8100. There are two ways to reach it, and they are not exclusive:
| Address | Transport | |
|---|---|---|
| Published port (always on) | http://your-host:8100/ — HOST_BLUEMAP_PORT |
Plain HTTP, separate port |
| Proxy route (opt-in) | https://your-host:8443/map/ — NGINX_BLUEMAP_PATH |
HTTPS, alongside the dashboard |
Setting NGINX_BLUEMAP_ENABLED=true adds the second: nginx proxies NGINX_BLUEMAP_PATH (default /map/) to the wrapper's BlueMap port, so the map is served over the same TLS connection as the dashboard.
NGINX_BLUEMAP_ENABLED=true
NGINX_BLUEMAP_PATH=/map/
BLUEMAP_URL=https://your-host:8443/map/ # what the dashboard links toIt is off by default because nothing listens on port 8100 until the BlueMap plugin is actually installed in the server's plugins/ directory — a route pointed at a dead upstream is a 502 waiting to happen. Install BlueMap first, then enable it.
BLUEMAP_URL is a separate setting and is not derived from these: it is whatever address you want the dashboard's map link to point at, so set it to match whichever of the two routes you intend players to use.
On Kubernetes the equivalents are nginx.bluemap.enabled and nginx.bluemap.path in helm/omcsi/values.yaml, with the same /map/ default. There is no published-port equivalent there — the wrapper's BlueMap port is only reachable through the proxy, so the route is the only way in.
Plugin JARs and world archives are uploaded through the nginx reverse proxy, so the proxy limit is the effective cap — a larger request is rejected with a bare 413 Request Entity Too Large before the dashboard's own error handling runs.
A world upload passes four limits in series, and the smallest one is what a client actually hits:
| Setting | Applies to | Default |
|---|---|---|
NGINX_MAX_BODY_SIZE |
Request body accepted by the proxy | 100M |
MAX_FILE_UPLOAD_SIZE / MAX_REQUEST_UPLOAD_SIZE |
Spring multipart limits, in both the web app and the wrapper | 2048MB |
WORLD_UPLOAD_MAX_FILE_SIZE_MB |
The wrapper's own check on the archive | 2048 |
WORLD_UPLOAD_MAX_EXTRACTED_MB |
Total extracted size — zip bomb protection | 10240 |
WORLD_UPLOAD_MAX_ENTRIES (default 100000) caps the entry count as further zip bomb protection.
To accept larger archives, raise all of them together. Note the suffixes differ: nginx uses 5G, Spring uses 5120MB, and the WORLD_UPLOAD_* values are plain MB. Raising only one silently leaves the effective cap where it was — and when NGINX_MAX_BODY_SIZE is the one that's too low, the rejection is a bare 413 Request Entity Too Large with no dashboard error at all.
Plugin uploads are written directly by the web app and are subject only to the first two rows.
Timeouts. A large upload takes minutes to transfer, and the wrapper does not respond until it has also stopped the server, extracted the archive, swapped the world in and restarted — so the request outlives nginx's ordinary 60s proxy timeout. NGINX_UPLOAD_TIMEOUT (default 3600s) is applied to the upload routes only, leaving the rest of the dashboard at 60s, and WORLD_UPLOAD_READ_TIMEOUT_SECONDS (default 600) is how long the web app waits on the wrapper. Raise both alongside the size limits; keep the web app's value at or below nginx's.
Buffering. The upload routes run with proxy_request_buffering off, so nginx streams the body through instead of spooling the whole archive to disk first. Each service still buffers the multipart body itself: MULTIPART_TEMP_DIR (empty by default) selects where. Empty means the container's own writable layer, which a multi-GB archive can exhaust — point it at a path on the mcserver volume (e.g. /mcserver/tmp) before accepting uploads that large. The directory is created at startup if missing.
A worked example — accepting a 5 GB world archive:
NGINX_MAX_BODY_SIZE=5G
MAX_FILE_UPLOAD_SIZE=5120MB
MAX_REQUEST_UPLOAD_SIZE=5120MB
WORLD_UPLOAD_MAX_FILE_SIZE_MB=5120
WORLD_UPLOAD_MAX_EXTRACTED_MB=30720 # worlds expand several times over
MULTIPART_TEMP_DIR=/mcserver/tmp
NGINX_UPLOAD_TIMEOUT=7200s
WORLD_UPLOAD_READ_TIMEOUT_SECONDS=7200On Kubernetes the same values are set through the Helm chart, or through the Terraform variables that drive it — see below.
On Kubernetes the equivalent chart values are nginx.maxBodySize, nginx.uploadTimeout, the MAX_*_UPLOAD_SIZE / MULTIPART_TEMP_DIR / WORLD_UPLOAD_* entries under minecraftWrapper.env, and MAX_*_UPLOAD_SIZE / MULTIPART_TEMP_DIR / WORLD_UPLOAD_READ_TIMEOUT_SECONDS under webapp.env, all in helm/omcsi/values.yaml.
The Hetzner Terraform module derives every one of them from a single variable so they cannot drift apart — set TF_VAR_world_upload_max_size_mb and nginx's cap, both services' multipart limits, and the wrapper's own check all move together. world_upload_max_extracted_mb, world_upload_max_entries, world_upload_timeout_seconds, multipart_temp_dir, and mcserver_storage_size are exposed alongside it. Note that the module aligns nginx.maxBodySize with the app-side limits rather than leaving it at the chart's 100M, so a Hetzner deployment does not silently cap uploads below what the services advertise.
Disk headroom for world uploads: a world archive is extracted into a staging directory next to the world directory — on the same volume — before being renamed into place, because a rename cannot cross filesystems. The server volume therefore needs enough free space to hold the old world and the extracted new one at the same time, roughly twice the size of the larger of the two. Plan for this when sizing the volume (persistence.mcserver.size on Kubernetes, default 10Gi); a world upload that runs out of space fails and leaves the existing world in place. The extracted size is capped at 10 GB regardless.
BACKUP_CONTAINER_NAME: Backup manager container name (default:open-mc-backup-manager)BACKUP_MAX_SIZE_MB: Maximum size of backups directory in MB (default:10240= 10GB)
Size this off the world, not off the default. After each backup, older backups are deleted oldest-first until the directory is under the cap — but the newest backup is never a deletion candidate. A cap smaller than one backup therefore leaves you holding exactly one backup and nothing else, and the run warns (and sends a
WARNINGalert) that the cap is too small to keep any history. Allow a few times the world size, and size the backups volume above the cap, since the cap is enforced after a backup is written rather than before.
BACKUP_SCHEDULE: Cron expression for backup schedule (default:0 0 2 * * ?= 2 AM daily). Set to-to disable scheduled backups entirely; manual backups viaPOST /api/backups/triggerandtrigger-backup.shkeep working.
See backup-manager/README.md for detailed cron expression examples and configuration.
ALERT_CONTAINER_NAME: Alert manager container name (default:open-mc-alert-manager)ALERT_PORT: Alert manager API port (default:8090)ALERT_MANAGER_URL: Alerts endpoint the other services POST to (default:http://alert-manager:8090/api/alerts). Docker Compose only — one.envvalue shared by minecraft-wrapper, the web app, backup-manager and agent-manager, all of which use it verbatim, so it must include the/api/alertspath. On Kubernetes the Helm chart derives it per service from the in-cluster alert-manager Service.DISCORD_WEBHOOK_URL: Discord webhook URL for sending notifications (optional)DISCORD_ENABLED: Enable/disable Discord notifications (default:false)
Alert Toggles - Fine-grained control over which events trigger alerts:
ALERTS_SERVER_START: Alert when server starts (default:true)ALERTS_SERVER_STOP: Alert when server stops gracefully (default:true)ALERTS_SERVER_CRASH: Alert when server crashes unexpectedly (default:true)ALERTS_BACKUP_SUCCESS: Alert when backup completes successfully (default:true)ALERTS_BACKUP_FAILURE: Alert when backup fails (default:true)ALERTS_PLUGIN_DEPLOY: Alert on plugin deployment success or failure (default:true)ALERTS_WORLD_UPLOAD: Alert on world upload success or failure (default:true)ALERTS_CONFIG_WARNING: Alert when server starts with configuration warnings (default:false)
The following three toggles are read by upgrade.sh, which runs on the Docker host and drives
docker compose directly. They apply to the Docker Compose deployment only and have no
Kubernetes equivalent, so the Helm chart does not expose them:
ALERTS_UPGRADE_START: Alert when upgrade process begins (default:true)ALERTS_UPGRADE_COMPLETE: Alert when the upgrade process finishes — either confirmed successful or with startup unverified from logs (default:true)ALERTS_UPGRADE_FAILURE: Alert when upgrade fails (default:true)
rollback.sh (also Docker Compose only) sends a completion alert on success and an ERROR alert if the restore fails partway through, both unconditionally — they aren't gated by a toggle, since a rollback is a rare, explicitly-confirmed action.
To enable Discord notifications:
- Create a webhook in your Discord server (Server Settings → Integrations → Webhooks)
- Copy the webhook URL and add it to your
.envfile - Set
DISCORD_ENABLED=true
The alert manager API is accessible on the configured port (default: 8090) for testing and integration from the host machine.
See alert-manager/README.md for detailed configuration and usage examples.
AGENT_CONTAINER_NAME: Agent manager container name (default:open-mc-agent-manager)AGENT_PORT: Agent manager API port (default:8093). The Spring Boot management/actuator endpoint also listens on8094inside the container but is not published to the host by default — seeagent-manager/README.mdfor details.AGENT_DISCORD_BOT_TOKEN: Discord bot token (required for agent manager)AGENT_DISCORD_CHANNEL_ID: Discord channel ID to listen on (required for agent manager)AGENT_ANTHROPIC_API_KEY: Anthropic API key (required for agent manager)AGENT_ANTHROPIC_MODEL: Anthropic model used for every Messages API call (default:claude-sonnet-4-20250514)AGENT_ANTHROPIC_MAX_TOKENS: Maximum output tokens per Anthropic response (default:1024). Raise this if answers summarisingget_server_diagnosticsoutput are being cut short; lower it to cap per-response API spend.AGENT_ENABLED: Enable/disable the agent manager (default:false)AGENT_START_SERVER_REQUIRES_CONFIRMATION: Require confirmation to start server (default:true)AGENT_STOP_SERVER_REQUIRES_CONFIRMATION: Require confirmation to stop server (default:true)AGENT_RESTART_SERVER_REQUIRES_CONFIRMATION: Require confirmation to restart server (default:true)AGENT_TRIGGER_BACKUP_REQUIRES_CONFIRMATION: Require confirmation to trigger a backup (default:true)
See agent-manager/README.md for detailed configuration, Discord bot setup, and usage examples.
The agent uses the get_server_diagnostics tool to answer open-ended health questions by
gathering context from multiple sources in a single pass — server status, recent alerts, the
latest backup result, live server performance metrics (e.g. JVM heap usage and TPS), and,
when enabled, recent sanitized log snippets controlled via the diagnostics.logs.*
configuration toggles — then synthesising a natural language summary.
Example interaction:
User: What happened while I was offline? Is everything okay?
Agent: The server has been running for 6 hours with 3 players online. There was a crash alert at 2:14 AM, after which the server restarted automatically. The last backup completed successfully at 2:00 AM, just before the crash. Current performance metrics (TPS and heap usage) look healthy. If sanitized diagnostic logs are enabled via
diagnostics.logs.*, I can also summarise any recent errors around 2:14 AM; otherwise, you may want to review the server logs directly to understand the crash cause.
This differs from a simple /status command because the agent reasons over the combined data rather than just returning a single API response. If any upstream source is unavailable the agent acknowledges the gap explicitly (e.g. "I wasn't able to reach the backup manager, but based on server status and recent alerts…"). Operators can control whether recent logs are included in diagnostics (and how they are anonymised) using the diagnostics.logs.* privacy toggles.
Once the minecraft-wrapper is up it sends one startup event to the project's usage
service, trace, at
https://trace.danielstephenson.dev, so the maintainers can see how many OMCSI deployments
exist and which versions they run. It is on by default.
What is sent: the program name (open-mc-server-infrastructure), the wrapper's version,
and any USAGE_REPORTING_TAGS you set. What is not: nothing per request or per player,
and nothing about your world, your operator account, your host or its address. The send runs
on its own daemon thread, never blocks startup and never fails the server — an unreachable
trace server is a dropped report, not an error. The wrapper logs one INFO line on every start
saying reporting is on and how to turn it off. The client is
trace-client-java vendored as one
file under com.openmc.minecraftwrapper.trace.
USAGE_REPORTING_ENABLED: Set tofalseto turn it off (default:true)USAGE_REPORTING_ENDPOINT: Where the event goes (default:https://trace.danielstephenson.dev)USAGE_REPORTING_KEY: The program key. Leave empty to use the project's bundled write-only key (default)USAGE_REPORTING_TAGS: Optional comma-separatedk=vpairs attached to the event, e.g.ci=trueso a CI deployment can be told apart from a real one.versionis reserved (default: empty)
To opt out on Docker Compose, set USAGE_REPORTING_ENABLED=false in .env and restart with
./up.sh. On Kubernetes the equivalents are the USAGE_REPORTING_* entries under
minecraftWrapper.env in helm/omcsi/values.yaml:
helm upgrade omcsi helm/omcsi --namespace omcsi --reuse-values \
--set minecraftWrapper.env.USAGE_REPORTING_ENABLED=falseThe services expose REST APIs, and clients/ holds official client libraries
for them so callers do not have to hand-roll curl.
clients/python/ — omcsi-client, a client for the
minecraft-wrapper, backup-manager and alert-manager APIs. Standard library
only, no runtime dependencies, type hints throughout.
pip install omcsi-clientfrom omcsi_client import Omcsi
omcsi = Omcsi(wrapper_url="http://localhost:8092", deploy_token="...")
print(omcsi.wrapper.status().running)
omcsi.wrapper.deploy_plugin("target/MyPlugin-1.2.3.jar", plugin_name="MyPlugin.jar")
omcsi.backups.trigger()
# Anything that interrupts a live game is behind `.disruptive`
# and will not run without confirm=True:
omcsi.wrapper.disruptive.restart(confirm=True)See clients/python/README.md for the full endpoint coverage, the timeout defaults for each class of call, the error model, and when to use the deposit box instead of an HTTP upload.
./up.shor
docker compose up -d --build./down.shor
docker compose downNote: The server includes graceful shutdown handling that automatically warns players before stopping. When a shutdown is initiated, players will receive countdown warnings at 30, 20, 10, and 5 seconds before the server stops. The server then sends the "stop" command to Minecraft, ensuring that plugins save their data properly and preventing data loss that could occur with an abrupt termination. The Docker Compose configuration includes a 45-second grace period to allow sufficient time for the warning sequence and graceful shutdown to complete.
docker logs -f open-mc-serverNote: Replace open-mc-server with your CONTAINER_NAME value if you've customized it.
The infrastructure includes a backup-manager service that automatically backs up the server data:
- Automatic Scheduling: Runs daily at 2 AM (configurable via
BACKUP_SCHEDULEin.env) - Size Management: Automatically removes old backups when the backup directory exceeds the configured size limit (default: 10GB)
- Containerized: Runs in its own container for isolation and reliability
To configure automated backups, set the following in your .env file:
# Maximum size of backups directory in MB (default: 10GB)
BACKUP_MAX_SIZE_MB=10240
# Backup schedule (cron expression, default: 2 AM daily)
BACKUP_SCHEDULE=0 0 2 * * ?View backup-manager logs:
docker logs -f open-mc-backup-managerSee backup-manager/README.md for detailed configuration options.
The backup-manager service automatically creates backups on a schedule (default: 2 AM daily). Backups are stored in the backups Docker volume (a named volume, not a host directory — see BACKUPS_VOLUME_NAME in .env) with timestamped names like backup-20241211-020000. List them with ./rollback.sh (no arguments), or directly via docker run --rm -v backups:/backups:ro alpine ls /backups.
To trigger a manual backup immediately:
./trigger-backup.shThis script uses the backup-manager REST API to create an immediate backup. Alternatively, you can restart the backup-manager container:
docker restart open-mc-backup-managerOr use Docker commands to manually copy server data:
docker cp open-mc-server:/mcserver ./backup/Note: Replace open-mc-server with your CONTAINER_NAME value if you've customized it.
docker cp ./backup/ open-mc-server:/mcserver
docker compose restartNote: Replace open-mc-server with your CONTAINER_NAME value if you've customized it.
The deposit-box directory is shared between your host system and the container at /deposit-box. Use it to transfer files to/from the server.
The easiest way to upgrade your Minecraft server to a new version:
./upgrade.shThis script automates the entire upgrade process:
- Stops the server gracefully
- Creates a timestamped backup automatically
- Prompts for the new version
- Updates configuration
- Rebuilds with the new version
- Starts the server
Preview an upgrade without making any changes with ./upgrade.sh --dry-run (optionally ./upgrade.sh --dry-run <version> to skip the version prompt). If an upgrade needs to be undone, ./rollback.sh restores a previous backup and restarts the server — see Rollback Procedure in the Upgrade Guide.
For a comprehensive, step-by-step guide to upgrading your Minecraft server to a newer version with proper backup and rollback procedures, see the Upgrade Guide.
The upgrade guide covers:
- Automated upgrade script usage (recommended)
- Manual step-by-step upgrade process
- Pre-upgrade backup procedures
- Rollback and restoration procedures
- Post-upgrade verification steps
- Troubleshooting common upgrade issues
To update the container without changing the Minecraft version:
./down.sh
docker compose build --no-cache
./up.sh- Check Docker logs:
docker logs open-mc-server(use yourCONTAINER_NAMEvalue) - Ensure all required environment variables are set
- Verify Docker and Docker Compose are installed
- Ensure port 25565 is open/forwarded (or your custom
HOST_PORTvalue) - Check if
ONLINE_MODEsetting matches your client type - Verify the server is running:
docker ps
- Adjust memory allocation in
sample.envby setting appropriate values - Monitor system resources:
docker stats open-mc-server(use yourCONTAINER_NAMEvalue)
One release per machine is the default and the simplest thing that works. It also puts a fixed monthly floor under every server, which is most of the cost for a small one. Several releases can share a cluster instead.
The chart is release-scoped throughout — every Deployment, Service, PVC and
Secret is named through omcsi.fullname — so two releases in one namespace
produce no colliding objects. What has to be arranged is the three things that
are node-wide rather than release-scoped: the HTTP port, the HTTPS port, and the
Minecraft port.
Ports run out; hostnames do not. Enable the Ingress and stop the Service claiming an address of its own:
ingress:
enabled: true
className: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: oak-hollow.example.com
tls:
- hosts: [oak-hollow.example.com]
secretName: oak-hollow-tls
nginx:
service:
type: ClusterIPnginx.service.type: ClusterIP matters. Leaving it as LoadBalancer asks for a
separate external address per release, which is the cost the Ingress exists to
avoid.
The Ingress routes to that release's own nginx, not to the webapp directly. nginx is where the upload body limit, the long upload timeouts and the BlueMap route live, so routing around it drops all three — and the first symptom is a bare 413 partway through a world upload.
The Minecraft protocol is neither HTTP nor TLS, so an HTTP Ingress cannot route it and there is no SNI to route on. Two options:
Separate ports. Leave minecraftWrapper.service.nodePort empty and each
release gets an auto-assigned port in the NodePort range. This works today and
needs nothing extra. Players connect to example.com:31234, which is
serviceable but easy to get wrong when typing it.
A Minecraft-aware proxy. The handshake packet carries the hostname the
player typed, so a proxy such as Velocity or BungeeCord can read it and route to
the right backend over a single port 25565 — oak.example.com and
gulf.example.com both on the standard port. This is how commercial hosts do
it.
OMCSI does not ship that proxy, and the chart's only job here is to stop
claiming 25565 so something else can hold it. Set
minecraftWrapper.service.type: ClusterIP and point the proxy at the resulting
Service.
Two things to know before going that way, because both are easy to discover late:
- Spigot supports BungeeCord-style forwarding (
settings.bungeecord: trueinspigot.yml). Velocity's modern forwarding requires Paper, which is not what this image builds. - Once a backend trusts a proxy for player identity, it must not be reachable directly. A backend in that mode accepts whatever username it is told, so an exposed one lets anyone connect as anyone. Keep the backends on ClusterIP and let only the proxy reach them.
The defaults assume a machine per server: 4Gi requested and 8Gi limit for
the wrapper alone, with JAVA_OPTS at -Xmx6G. Three of those want a node with
somewhere north of 12Gi before anything else is scheduled.
Co-location means revisiting those numbers per release rather than inheriting
them, and the memory limit and -Xmx have to move together — a heap larger than
the limit is an OOMKill under load rather than a garbage collection.
Each release also keeps its own PersistentVolumeClaims, so disk adds up in the
same way. On a single-node cluster using local-path, that is all one disk.
- HTTPS Enabled: All web dashboard connections are encrypted using HTTPS to protect admin credentials
- Change default operator settings in
.env - Change default admin credentials: Update
ADMIN_USERNAMEandADMIN_PASSWORDin.env - Production SSL: Replace self-signed certificates with trusted CA certificates (e.g., Let's Encrypt) for production
- Consider setting
ONLINE_MODE=truefor authentication - Don't expose the server publicly without proper security measures
- Regularly backup your world data
- Keep
RCON_PASSWORDsecure and different from default values
.env holds every credential in plaintext by default. That is workable for a
server you run alone on a machine you own, and it stops being workable the
moment the file is shared between machines — because the usual way to share it
is to commit it, and a value committed once stays in the history forever.
Rotating a leaked secret means rotating it at the provider and rewriting
history if you need the old value genuinely gone.
Any value in .env can instead be written as a reference, fetched when the
stack starts rather than stored:
RCON_PASSWORD=!ref:cmd:sops -d --extract '["rcon_password"]' secrets.enc.yaml
HCLOUD_TOKEN=!ref:file:~/.secrets/hcloud-token
DISCORD_WEBHOOK_URL=!ref:env:OMCSI_DISCORD_WEBHOOKThree schemes are supported — env:, file:, and cmd:. The SOPS example
above needs no additional infrastructure: the encrypted file is safe to commit,
the key that opens it is not.
This is optional and backwards compatible. A .env of plain literals keeps
working exactly as before, and ./up.sh does no extra work unless a !ref: is
actually present. The !ref: sigil exists so that a literal can never be
mistaken for a reference: a password of file:something stays that password.
If any reference fails to resolve, nothing starts. There is no partial startup with a blank password.
To resolve values into your own shell — before terraform apply, for instance:
source <(./scripts/resolve-secrets.sh --format shell)One caveat.
upgrade.sh,rollback.shandtrigger-backup.shread a few values straight out of.envwithgrep— container and volume names only, never secrets. Do not put a reference onCONTAINER_NAME,VOLUME_NAME, orBACKUPS_VOLUME_NAME; those would be read literally.
Worth knowing before deciding what to keep where:
| Credential | If it leaks |
|---|---|
RCON_PASSWORD, ADMIN_PASSWORD, DEPLOY_AUTH_TOKEN, DISCORD_WEBHOOK_URL |
Rotate at the source and the old value is dead. Recoverable. |
| Cloud provider API token | Rotate and the old value is dead — but until you do, it can create and destroy servers and spend real money. |
| kubeadm cluster-admin kubeconfig | Not revocable. Kubernetes has no certificate revocation list, so a leaked admin certificate stays valid until it expires no matter what you change afterwards. Undoing the leak means rotating the cluster CA and re-issuing every credential on the node. |
The last row is the one that catches people out. A kubeconfig written by
terraform apply is a cluster-admin certificate, and copying it somewhere
convenient — a second machine, a private repository — is a decision that cannot
be reversed later. Treat it differently from the passwords above, because it
behaves differently.
Provisioning produces a cluster-admin kubeconfig, and it is easy for that to become the credential everything uses forever — monitoring, scripts, a second laptop — because it is the one that already exists and it always works.
Set rbac.enabled=true to get a namespace-scoped alternative:
helm upgrade omcsi helm/omcsi --namespace omcsi --reuse-values \
--set rbac.enabled=true
./scripts/service-account-kubeconfig.sh \
--account omcsi-viewer --namespace omcsi \
--duration 8h --output ~/omcsi-viewer.kubeconfigTwo accounts, because reading the server and changing it are different jobs:
omcsi-viewer |
omcsi-operator (rbac.operator.enabled=true) |
|
|---|---|---|
| Pods, deployments, services, events, PVCs | read | read/write |
| Pod logs | yes | yes |
kubectl exec into a container |
no | yes |
| Read Secrets (RCON, admin password) | no | yes |
helm upgrade, helm get values |
no | yes |
| Anything outside this namespace | no | no |
omcsi-viewer is safe to hand to someone who should see whether the server is
healthy without being able to operate it. omcsi-operator is effectively
admin of this namespace — two grants are easy to assume it lacks: pods/exec
is a console inside the container, and Secret access is unavoidable because Helm
stores every release revision as a Secret, which necessarily includes the one
holding your passwords.
So the operator account is a large reduction from cluster-admin — no other namespace, no nodes, no cluster-scoped resources — but it is not a low-privilege credential.
What it is, and the admin certificate is not:
-
Time-bound.
--durationis honoured by the cluster (which may cap it). The credential stops working on its own. -
Revocable. Deleting the ServiceAccount invalidates its tokens:
kubectl -n omcsi delete serviceaccount omcsi-viewer
Not instantly — the API server caches successful token authentication for a few seconds, measured at about 12 seconds on a kubeadm 1.34 cluster. That is a rounding error next to a certificate valid for a year, but it is not zero.
Both accounts are namespace-scoped Roles, never ClusterRoles, and nothing is
created unless you ask: rbac.enabled defaults to false, because a chart
should not mint credentials nobody requested.
Docker Compose has no equivalent and needs none. There is no API server to authenticate to — access to the stack is access to the Docker socket on the host, which is governed by the host's own user permissions.
For comprehensive security guidance, especially for public/home hosting, see the Self-Hosting Guide which covers:
- Firewall configuration (UFW, iptables, OPNsense, pfSense)
- DDoS protection and rate limiting
- Port forwarding best practices
- Network security hardening
- Advanced security configurations
This project is licensed under the MIT License - see the LICENSE file for details.
The infrastructure consists of several Spring Boot modules:
Handles alert notifications to Discord and sends messages to Minecraft players via RCON.
- Port: 8090
- Location:
alert-manager/ - Documentation: alert-manager/README.md
Automated backup service with scheduling and size management.
- Port: 8091
- Location:
backup-manager/ - Documentation: backup-manager/README.md
Spring Boot service providing testable, REST-accessible wrapper functionality for Minecraft server management. This module is integrated into the main Minecraft server container.
- Location:
minecraft-wrapper/ - Documentation: minecraft-wrapper/README.md
- Features:
- Unit-tested server lifecycle management
- REST API for server status, commands, and messaging (port 8092)
- Graceful shutdown with player warnings
- Alert integration
- Process management for Minecraft server
Client library for the REST APIs above, published to PyPI as omcsi-client.
- Location:
clients/python/ - Documentation: clients/python/README.md
- Features:
- One client per service, plus an
Omcsifacade over all three - Standard library only; no runtime dependencies
- Distinguishes a refusal by the service from an unreachable service
- Disruptive calls (stop, restart, shutdown, console command, world replace)
require an explicit
confirm=True
- One client per service, plus an
Spring Boot web dashboard for server management and monitoring.
- Port: 8080 (behind nginx proxy on 8443)
- Location:
web-app/
Discord-based server management agent powered by the Anthropic API. Users send natural language messages in a Discord channel to start, stop, or restart the Minecraft server.
- Port: 8093
- Location:
agent-manager/ - Documentation: agent-manager/README.md
- Features:
- Natural language Discord commands via Anthropic tool-use API
- Start, stop, and restart server tools
- Per-tool confirmation flow via Discord reactions
- Requesting-user validation on confirmations
- Disabled by default (
AGENT_ENABLED=false)
This repository includes a comprehensive CI pipeline that automatically validates:
- Shell Script Validation: Syntax checking and ShellCheck linting for all bash scripts
- Docker Configuration: Validates Dockerfile and Docker Compose configurations
- Environment Configuration: Ensures all required environment variables are properly defined
- nginx Configuration: Builds the nginx image and asserts the routes it actually resolves
- Python Client: Installs and tests
clients/pythonon the oldest and newest supported Python versions - Security Scanning: Trivy security scanning for vulnerabilities
- Server Run Testing: Actually runs the Minecraft server to verify it starts, operates, and stops correctly
- Integration Testing: End-to-end validation of the complete setup
Before submitting changes, you can run the same validation checks locally:
./scripts/ci-local.shThis mirrors the CI pipeline to catch issues early: shell script syntax and
ShellCheck linting, Docker Compose configuration, the nginx route configuration
test, environment and documentation checks, helm lint, helm unittest,
Terraform formatting and validation for all four targets, the Gradle test
suite for every module, and the Python client's test suite. Checks whose tool (ShellCheck, Helm, the helm-unittest
plugin, Terraform, a reachable Docker daemon) is unavailable locally are skipped
with a warning and listed in the summary at the end of the run.
The CI pipeline runs on:
- Every push to
main - Every pull request to
main
Check the Actions tab for detailed CI results and logs.
Feel free to submit issues and enhancement requests!