Skip to content

Instantly share code, notes, and snippets.

@rclark
Last active February 18, 2023 20:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rclark/057059bfbd869743d1742a95b456bcff to your computer and use it in GitHub Desktop.
Save rclark/057059bfbd869743d1742a95b456bcff to your computer and use it in GitHub Desktop.
satisfactory dedicated server on aws

Satisfactory dedicated server on AWS ECS

A CloudFormation stack that you can run in your AWS account to host up a dedicated Satisfactory server.

Thanks to https://github.com/wolveix/satisfactory-server for the Docker image!

Runs on AWS ECS

The dedicated server application runs on ECS Fargate, so you get a more-or-less "serverless" setup. It uses Fargate Spot, which allows you to get the cheapest possible setup, though AWS may choose to stop and restart your server. FWIW I've never actually observed that happening.

Files and backups

The game files and saves are stored on EFS, a network-attached storage system that allows these files to persist when/if ECS tasks stop and restart. On a daily basis, the save files are copied up from EFS to an S3 bucket in your account, named satistfactory-backups-{aws account id number}. This makes for a cheap daily backup + easier access to those files.

Networking and connecting to the dedicated server

When an ECS task launches, it gets a public IP address, with the exposed ports required to access the dedicated server application from the Satisfactory game client. However, if the task/container ever stops, a new one will launch to replace it, and it will have a new IP address. Because of this, we need to work with DNS records that we can update.

You must bring your own domain name that you own, provided as a stack parameter. For example, I might own a domain rclark.life. The stack builds a Route53 hosted zone for a subdomain of your domain, for example satisfactory.rclark.life. That hosted zone's name servers are a stack output. After launching the stack, you are responsible for making an NS record under the owned domain that references these name servers. That (in a sense) forwards traffic through your domain registrar to AWS Route53.

The stack also creates a Lambda function. Every time a new ECS task starts, the Lambda function runs. It finds out the new container's IP address, and updates an A record in the Route53 hosted zone, for example www.satisfactory.rclark.life.

That means in the Satisfactory game client, you connect to the server at a domain name like, for example, www.satisfactory.rclark.life.

If the server application crashes (and it will), or if AWS stops your task (I haven't noticed), you will have to exit your Satisfactory game client all the way to your desktop. Wait a few minutes before launching it again. In that time a new ECS task launches, and the DNS A record gets updated by the Lambda function. It appears that the game client will only do the DNS lookup when the client launches, so you do have to exit the client and start it again after the record has been updated.

Costs

Roughly, it seems to cost about $50-60 USD per month to run this setup 24/7. Almost all of that cost is from running the ECS Fargate Spot task constantly. My AWS bill last month was $60.08, and $46.03 of that was ECS.

You can turn the dedicated server off and back on again by making adjustments to the ECS service's desired task count. The ECS service can be found in the ECS console by browsing to the games cluster. That cluster should host just 1 service called satisfactory-server.

Set the number of desired tasks to 0 to tell ECS to run nothing. When you want to play again, set it back to 1. If you do this, you'll reduces the monthly cost dramatically... unless you actually play for most of the day on most days, in which case you're just gonna have to pay up.

Note: Never set the desired task count > 1. There'd be 2 dedicated servers trying to access the same gamefiles and save files at that point, and things would definitely get weird.

Some troubleshooting

Here are some aws-cli commands you can use to try and troubleshoot anything going wrong. Make sure you set the region properly for whatever AWS region you launched the stack into. Either add --region flags, or setup a default region in your ~/.aws/config file.

Turn the dedicated server off and on

# OFF
aws ecs update-service \
    --cluster games \
    --service satisfactory-server \
    --desired-count 0
    
## ON
aws ecs update-service \
    --cluster games \
    --service satisfactory-server \
    --desired-count 1

Make an SSH connection to the running container

aws ecs execute-command  \
    --cluster games \
    --task $(aws ecs list-tasks \
                --service-name satisfactory-server \
                --cluster games \
                --query "taskArns[0]" \
                --output text) \
    --container satisfactory-server \
    --command "/bin/bash" \
    --interactive

Find the IP address of the currently running container

aws ec2 describe-network-interfaces \
    --network-interface-ids $(aws ecs describe-tasks \
            --cluster games \
            --tasks $(aws ecs list-tasks \
                --service-name satisfactory-server \
                --cluster games \
                --query "taskArns[0]" \
                --output text) \
            --query "tasks[0].attachments[0].details[1].value" \
            --output text) \
    --query "NetworkInterfaces[0].Association.PublicIp" \
    --output text

Tell Lambda to update the DNS record

aws lambda invoke \
    --function-name satisfactory-dns-refresher \
    --invocation-type EVENT \
    --payload '{}'
Parameters:
TopLevelDomainName:
Type: String
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Ref AWS::StackName
DHCP:
Type: AWS::EC2::DHCPOptions
Properties:
DomainName: !Sub ${AWS::Region}.compute.internal
DomainNameServers:
- AmazonProvidedDNS
DHCPAssociation:
Type: AWS::EC2::VPCDHCPOptionsAssociation
Properties:
VpcId: !Ref VPC
DhcpOptionsId: !Ref DHCP
Gateway:
Type: AWS::EC2::InternetGateway
GatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref Gateway
RouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref Subnet
RouteTableId: !Ref RouteTable
InternetRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref RouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref Gateway
Subnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.128.0/20
AvailabilityZone: !Sub "${AWS::Region}a"
Disk:
Type: AWS::EFS::FileSystem
Properties:
Encrypted: true
LifecyclePolicies:
- TransitionToIA: AFTER_14_DAYS
PerformanceMode: generalPurpose
ThroughputMode: bursting
Mount:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref Disk
SecurityGroups:
- !GetAtt DiskAccess.GroupId
SubnetId: !Ref Subnet
AccessPoint:
Type: AWS::EFS::AccessPoint
Properties:
FileSystemId: !Ref Disk
PosixUser:
Uid: "1000"
Gid: "1000"
RootDirectory:
Path: /home/satisfactory-server
CreationInfo:
OwnerUid: "1000"
OwnerGid: "1000"
Permissions: "755"
DiskAccess:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: !Ref VPC
GroupDescription: Access to satisfactory EFS disk
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 2049
ToPort: 2049
CidrIp: 0.0.0.0/0
Task:
Type: AWS::ECS::TaskDefinition
Properties:
Cpu: "4096"
Memory: "12288"
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
TaskRoleArn: !GetAtt TaskRole.Arn
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
ContainerDefinitions:
- Name: satisfactory-server
Image: wolveix/satisfactory-server
PortMappings:
- ContainerPort: 7777
Protocol: udp
- ContainerPort: 15000
Protocol: udp
- ContainerPort: 15777
Protocol: udp
MountPoints:
- ContainerPath: /config
SourceVolume: disk
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref Logs
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: !Ref AWS::StackName
Command:
- --disable-telemetry
Environment:
- Name: PGID
Value: "1000"
- Name: PUID
Value: "1000"
- Name: DISABLESEASONALEVENTS
Value: "true"
- Name: AUTOPAUSE
Value: "false"
Volumes:
- Name: disk
EFSVolumeConfiguration:
FilesystemId: !Ref Disk
TransitEncryption: ENABLED
TransitEncryptionPort: 2050
AuthorizationConfig:
AccessPointId: !Ref AccessPoint
IAM: ENABLED
TaskRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: ecs-tasks.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action:
- elasticfilesystem:ClientMount
- elasticfilesystem:ClientWrite
Resource: !GetAtt Disk.Arn
Condition:
StringEquals:
elasticfilesystem:AccessPointArn: !GetAtt AccessPoint.Arn
- PolicyName: exec
PolicyDocument:
Statement:
- Effect: Allow
Action:
- ssmmessages:CreateControlChannel
- ssmmessages:CreateDataChannel
- ssmmessages:OpenControlChannel
- ssmmessages:OpenDataChannel
Resource: "*"
- Effect: Allow
Action: kms:Decrypt
Resource: !GetAtt ExecKey.Arn
ExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: ecs-tasks.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action: logs:*
Resource: !GetAtt Logs.Arn
TaskAccess:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: !Ref VPC
GroupDescription: Ingress for satisfactory-server
SecurityGroupIngress:
- IpProtocol: udp
FromPort: 7777
ToPort: 7777
CidrIp: 0.0.0.0/0
- IpProtocol: udp
FromPort: 15000
ToPort: 15000
CidrIp: 0.0.0.0/0
- IpProtocol: udp
FromPort: 15777
ToPort: 15777
CidrIp: 0.0.0.0/0
Logs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Ref AWS::StackName
RetentionInDays: 14
Cluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: games
Service:
Type: AWS::ECS::Service
Properties:
CapacityProviderStrategy:
- CapacityProvider: FARGATE_SPOT
Weight: 1
Cluster: !Ref Cluster
DesiredCount: 1
EnableECSManagedTags: true
EnableExecuteCommand: true
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: ENABLED
SecurityGroups:
- !GetAtt TaskAccess.GroupId
Subnets:
- !Ref Subnet
ServiceName: satisfactory-server
TaskDefinition: !Ref Task
ExecKey:
Type: AWS::KMS::Key
Properties:
KeyPolicy:
Version: 2012-10-17
Id: key-default-1
Statement:
- Sid: Default
Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: kms:*
Resource: "*"
Domain:
Type: AWS::Route53::HostedZone
Properties:
Name: !Sub "satisfactory.${TopLevelDomainName}"
Refresher:
Type: AWS::Lambda::Function
Properties:
FunctionName: satisfactory-dns-refresher
Role: !GetAtt RefresherRole.Arn
Handler: index.handler
Runtime: nodejs14.x
Code:
ZipFile: !Sub |
const AWS = require('aws-sdk');
exports.handler = async () => {
const ecs = new AWS.ECS();
const ec2 = new AWS.EC2();
const r53 = new AWS.Route53();
const tasks = await ecs.listTasks({
cluster: 'games',
serviceName: 'satisfactory-server'
}).promise();
if (tasks.taskArns.length === 0) return;
const desc = await ecs.describeTasks({
cluster: 'games',
tasks: [tasks.taskArns[0]]
}).promise();
if (desc.tasks.length === 0) return;
const eni = desc.tasks[0].attachments[0].details.find((a) => a.name === 'networkInterfaceId').value;
const interface = await ec2.describeNetworkInterfaces({
NetworkInterfaceIds: [eni]
}).promise();
if (interface.NetworkInterfaces.length === 0) return;
await r53.changeResourceRecordSets({
HostedZoneId: '${Domain.Id}',
ChangeBatch: {
Changes: [{
Action: 'UPSERT',
ResourceRecordSet: {
Name: 'www.satisfactory.${TopLevelDomainName}',
Type: 'A',
TTL: 60,
ResourceRecords: [{
Value: interface.NetworkInterfaces[0].Association.PublicIp
}]
}
}]
}
}).promise();
};
RefresherLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/satisfactory-dns-refresher
RetentionInDays: 14
RefresherRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: lambda.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action: logs:*
Resource: !GetAtt Logs.Arn
- Effect: Allow
Action:
- ecs:DescribeTasks
- ecs:ListTasks
- ec2:DescribeNetworkInterfaces
- route53:ChangeResourceRecordSets
Resource: "*"
RefresherEvents:
Type: AWS::Events::Rule
Properties:
Name: satisfactory-server-dns-refresh-termination
EventPattern:
source:
- aws.ecs
detail-type:
- ECS Task State Change
detail:
lastStatus:
- RUNNING
clusterArn:
- !GetAtt Cluster.Arn
Targets:
- Id: satisfactory-server-dns-refresher
Arn: !GetAtt Refresher.Arn
EventsPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref Refresher
Principal: events.amazonaws.com
SourceArn: !GetAtt RefresherEvents.Arn
BackupBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub satisfactory-backups-${AWS::AccountId}
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Status: Enabled
AbortIncompleteMultipartUpload:
DaysAfterInitiation: 1
BackupSource:
Type: AWS::DataSync::LocationEFS
Properties:
EfsFilesystemArn: !GetAtt Disk.Arn
Subdirectory: /home/satisfactory-server/saved/server # on ECS task, saves in /config/saved/server
Ec2Config:
SecurityGroupArns:
- !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:security-group/${DiskAccess.GroupId}
SubnetArn: !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${Subnet}
BackupDestination:
Type: AWS::DataSync::LocationS3
Properties:
S3BucketArn: !GetAtt BackupBucket.Arn
S3Config:
BucketAccessRoleArn: !GetAtt BackupRole.Arn
S3StorageClass: STANDARD_IA
Subdirectory: saves # in S3, everything ends up under saves/ key
BackupRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: datasync.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action:
- s3:GetBucketLocation
- s3:ListBucket
- s3:ListBucketMultipartUploads
Resource: !GetAtt BackupBucket.Arn
- Effect: Allow
Action:
- s3:AbortMultipartUpload
- s3:DeleteObject
- s3:GetObject
- s3:ListMultipartUploadParts
- s3:PutObjectTagging
- s3:GetObjectTagging
- s3:PutObject
Resource: !Sub ${BackupBucket.Arn}/*
BackupTask:
Type: AWS::DataSync::Task
Properties:
DestinationLocationArn: !Ref BackupDestination
SourceLocationArn: !Ref BackupSource
Name: satisfactory-backups
Options:
Atime: BEST_EFFORT
Mtime: PRESERVE
Schedule:
ScheduleExpression: rate(1 days)
Outputs:
NameServers:
Value: !Join
- ","
- !GetAtt Domain.NameServers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment