Skip to content

Instantly share code, notes, and snippets.

@rclark
Last active January 24, 2025 14:39
Show Gist options
  • Select an option

  • Save rclark/057059bfbd869743d1742a95b456bcff to your computer and use it in GitHub Desktop.

Select an option

Save rclark/057059bfbd869743d1742a95b456bcff to your computer and use it in GitHub Desktop.

Revisions

  1. rclark revised this gist Feb 18, 2023. No changes.
  2. rclark revised this gist Feb 18, 2023. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -6,7 +6,7 @@ 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, at the cost that sometimes AWS may choose to stop and restart your server. FWIW I've never actually observed that happening.
    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

  3. rclark revised this gist Feb 18, 2023. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -1,5 +1,7 @@
    # 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
  4. rclark revised this gist Feb 18, 2023. 2 changed files with 82 additions and 13 deletions.
    93 changes: 81 additions & 12 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -1,25 +1,94 @@
    # Satisfactory dedicated server on AWS ECS

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

    - 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, at the cost that sometimes AWS may choose to stop and restart your server. FWIW I've never actually observed that happening.
    ## Runs on AWS ECS

    - 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.
    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, at the cost that sometimes AWS may choose to stop and restart your server. FWIW I've never actually observed that happening.

    - On a daily basis, the save files are backed up to an S3 bucket in your account, named `satistfactory-backups-{aws account id number}`.
    ## Files and backups

    - 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.
    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.

    - You must bring your own domain name that you own, provided as a stack parameter. For example, I might own a domain `rclark.life`.
    ## Networking and connecting to the dedicated server

    - 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.
    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.

    - The stack creates a Lambda function. Every time a new ECS task starts, the Lambda function is invoked. It finds out the new container's IP address, and updates an A record in the hosted zone, for example `www.satisfactory.rclark.life`.
    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.

    - That means in the Satisfactory game client, you connect to the server at that domain name, e.g. `www.satisfactory.rclark.life`.
    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`.

    - 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. It appears that the game client will only do the DNS lookup when the client launches.
    **That means in the Satisfactory game client, you connect to the server at a domain name like, for example, `www.satisfactory.rclark.life`.**

    - Roughly, it seems to cost about 50-60 USD per month to run this setup. Almost all of that cost is from running the ECS Fargate Spot task 24 hours a day, 7 days a week.
    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.

    - You can turn the dedicated server off and back on again by making adjustments to the ECS service's "desired task count".
    ## 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

    ```sh
    # 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

    ```sh
    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

    ```sh
    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

    ```sh
    aws lambda invoke \
    --function-name satisfactory-dns-refresher \
    --invocation-type EVENT \
    --payload '{}'
    ```
    2 changes: 1 addition & 1 deletion satisfactory-server.yaml
    Original file line number Diff line number Diff line change
    @@ -58,7 +58,7 @@ Resources:
    Properties:
    VpcId: !Ref VPC
    CidrBlock: 10.0.128.0/20
    AvailabilityZone: us-west-2a
    AvailabilityZone: !Sub "${AWS::Region}a"

    Disk:
    Type: AWS::EFS::FileSystem
  5. rclark revised this gist Feb 18, 2023. 1 changed file with 25 additions and 0 deletions.
    25 changes: 25 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,25 @@
    # Satisfactory dedicated server on AWS ECS

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

    - 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, at the cost that sometimes AWS may choose to stop and restart your server. FWIW I've never actually observed that happening.

    - 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 backed up to an S3 bucket in your account, named `satistfactory-backups-{aws account id number}`.

    - 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.

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

    - That means in the Satisfactory game client, you connect to the server at that domain name, e.g. `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. It appears that the game client will only do the DNS lookup when the client launches.

    - Roughly, it seems to cost about 50-60 USD per month to run this setup. Almost all of that cost is from running the ECS Fargate Spot task 24 hours a day, 7 days a week.

    - You can turn the dedicated server off and back on again by making adjustments to the ECS service's "desired task count".
  6. rclark created this gist Feb 18, 2023.
    460 changes: 460 additions & 0 deletions satisfactory-server.yaml
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,460 @@
    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: us-west-2a

    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