├── .gitignore ├── readme-spot-pricing.jpg ├── .github └── workflows │ └── blank.yml ├── util ├── delete-filesystems.bash ├── upload-save.bash └── download-latest-save.bash ├── LICENSE ├── cf.yml └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw[a-p] 2 | *.zip -------------------------------------------------------------------------------- /readme-spot-pricing.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-chandler/factorio-spot-pricing/HEAD/readme-spot-pricing.jpg -------------------------------------------------------------------------------- /.github/workflows/blank.yml: -------------------------------------------------------------------------------- 1 | name: Push to S3 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup AWS CLI 15 | uses: aws-actions/configure-aws-credentials@v1 16 | with: 17 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 18 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 19 | aws-region: us-east-1 20 | - name: Copy cf.yml to S3 bucket 21 | run: | 22 | aws s3 cp cf.yml s3://factorio-spot-pricing 23 | -------------------------------------------------------------------------------- /util/delete-filesystems.bash: -------------------------------------------------------------------------------- 1 | # Get all of the filesystems to delete 2 | # aws efs describe-file-systems --query "FileSystems[?Name!=null]|[?starts_with(Name, 'factorio-')].FileSystemId" --output text | cat 3 | 4 | # For each filesystem whose name starts with "factorio-" delete it. 5 | # This is a very very dangerous script. Only use it if you know what you are doing. 6 | # I recommend you download your save files first using download-latest-save.bash 7 | 8 | for fs_id in $(aws efs describe-file-systems --query "FileSystems[?Name!=null]|[?starts_with(Name, 'factorio-')].FileSystemId" --output text | cat) 9 | do 10 | echo "Deleting file system: $fs_id" 11 | aws efs delete-file-system --file-system-id $fs_id 12 | if [ $? -eq 0 ]; then 13 | echo "Successfully deleted file system: $fs_id" 14 | else 15 | echo "Failed to delete file system: $fs_id" 16 | fi 17 | done -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Michael Chandler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /util/upload-save.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if both arguments are provided 4 | if [ $# -ne 2 ]; then 5 | echo "Usage: $0 " 6 | exit 1 7 | fi 8 | 9 | # Get the file path and EC2 address from command line arguments 10 | save_file="$1" 11 | ec2_address="$2" 12 | key_path="" 13 | 14 | # Check if custom PEM file is provided 15 | if [ -n "$FACTORIO_PEM" ]; then 16 | key_path="-i $FACTORIO_PEM" 17 | fi 18 | 19 | # Check if the file exists 20 | if [ ! -f "$save_file" ]; then 21 | echo "File not found: $save_file" 22 | exit 1 23 | fi 24 | 25 | # Upload the save file to the EC2 instance 26 | echo "Uploading save file to EC2 instance..." 27 | scp $key_path "$save_file" "ec2-user@$ec2_address:~/" 28 | 29 | # SSH into the EC2 instance and perform the required operations 30 | ssh $key_path "ec2-user@$ec2_address" << EOF 31 | # Get the Factorio container ID 32 | container_id=\$(docker ps | grep factoriotools/factorio | awk '{print \$1}' | cut -c1-3) 33 | 34 | if [ -z "\$container_id" ]; then 35 | echo "Factorio container not found" 36 | exit 1 37 | fi 38 | 39 | echo "Factorio container ID: \$container_id" 40 | 41 | # Find the save directory 42 | savedir=\$(mount | grep nfs4 | cut -f3 -d ' ' | xargs -I {} echo "{}/saves") 43 | echo "Save directory: \$savedir" 44 | 45 | # Move the uploaded save to the right location 46 | sudo mv ~/$(basename "$save_file") \$savedir 47 | 48 | # Touch the save file to update its timestamp 49 | sudo touch \$savedir/$(basename "$save_file") 50 | 51 | # Force kill the Factorio docker container 52 | echo "Killing Factorio container..." 53 | docker kill \$container_id 54 | 55 | echo "Save file uploaded and container restarted. Please wait 30 seconds for the server to come back online." 56 | EOF 57 | 58 | echo "Script completed. The server should load your new save file when it restarts." -------------------------------------------------------------------------------- /util/download-latest-save.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if remote name is provided 4 | if [ $# -ne 1 ]; then 5 | echo "Usage: $0 " 6 | exit 1 7 | fi 8 | 9 | remote_name="$1" 10 | key_path="" 11 | 12 | # Check if custom PEM file is provided 13 | if [ -n "$FACTORIO_PEM" ]; then 14 | key_path="-i $FACTORIO_PEM" 15 | fi 16 | 17 | # Generate a human-readable timestamp 18 | timestamp=$(date +"%Y-%m-%d_%H-%M-%S") 19 | 20 | 21 | 22 | # SSH into the remote instance to find the most recent save file 23 | ssh_output=$(ssh $key_path "ec2-user@$remote_name" << EOF 24 | # Record the current directory 25 | current_dir=\$(pwd) 26 | 27 | # Find the save directory 28 | savedir=\$(sudo mount | grep nfs4 | cut -f3 -d ' ' | xargs -I {} echo "{}/saves") 29 | echo "Save directory: \$savedir" 30 | 31 | if [ -z "\$savedir" ]; then 32 | echo "ERROR: Save directory not found" 33 | exit 1 34 | fi 35 | 36 | # Find the most recently modified file in the save directory 37 | latest_file=\$(sudo ls -t \$savedir | head -1) 38 | 39 | if [ -z "\$latest_file" ]; then 40 | echo "ERROR: No files found in the save directory" 41 | exit 1 42 | fi 43 | 44 | echo "Latest save file: \$latest_file" 45 | 46 | # Copy the latest file to the current directory 47 | sudo cp "\$savedir/\$latest_file" "\$current_dir/" 48 | 49 | # Change ownership of the copied file to ec2-user 50 | sudo chown ec2-user:ec2-user "\$current_dir/\$latest_file" 51 | 52 | echo "\$current_dir/\$latest_file" 53 | EOF 54 | ) 55 | 56 | # Check if there was an error in the SSH command 57 | if echo "$ssh_output" | grep -q "ERROR:"; then 58 | echo "$ssh_output" 59 | exit 1 60 | fi 61 | 62 | # Extract the full path of the latest file 63 | latest_file_path=$(echo "$ssh_output" | tail -n 1) 64 | 65 | # Extract just the filename 66 | latest_file=$(basename "$latest_file_path") 67 | 68 | # Download the file from the remote instance to the current local directory with the new filename 69 | new_filename="${remote_name}_${timestamp}_${latest_file}" 70 | scp $key_path "ec2-user@$remote_name:$latest_file_path" "./$new_filename" 71 | 72 | # Clean up the temporary file on the remote instance 73 | ssh $key_path "ec2-user@$remote_name" "rm -f $latest_file_path" 74 | 75 | echo "Download complete. The latest save file has been saved as '$new_filename' in your current directory." -------------------------------------------------------------------------------- /cf.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Description: Factorio Spot Price Server via Docker / ECS 3 | Parameters: 4 | 5 | ECSAMI: 6 | Description: AWS ECS AMI ID 7 | Type: AWS::SSM::Parameter::Value 8 | Default: /aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id 9 | 10 | FactorioImageTag: 11 | Type: String 12 | Description: "(Examples include latest, stable, 0.17, 0.17.33) Refer to tag descriptions available here: https://hub.docker.com/r/factoriotools/factorio/)" 13 | Default: stable 14 | 15 | ServerState: 16 | Type: String 17 | Description: "Running: A spot instance will launch shortly after setting this parameter; your Factorio server should start within 5-10 minutes of changing this parameter (once UPDATE_IN_PROGRESS becomes UPDATE_COMPLETE). Stopped: Your spot instance (and thus Factorio container) will be terminated shortly after setting this parameter." 18 | Default: Running 19 | AllowedValues: 20 | - Running 21 | - Stopped 22 | 23 | InstancePurchaseMode: 24 | Type: String 25 | Description: "Spot: Much cheaper, but your instance might restart during gameplay with a few minutes of unsaved gameplay lost. On Demand: Instance will be created in on-demand mode. More expensive, but your gameplay is unlikely to be interrupted by the server going down." 26 | Default: "Spot" 27 | AllowedValues: 28 | - "On Demand" 29 | - "Spot" 30 | 31 | InstanceType: 32 | Type: String 33 | Description: "Spot: You should leave this blank to get the best value instance. Override at your discretion: https://aws.amazon.com/ec2/instance-types/. On Demand: You must specify this. " 34 | Default: "" 35 | 36 | SpotPrice: 37 | Type: String 38 | Description: "Spot: the max cents/hr to pay for spot instance. On Demand: Ignored" 39 | Default: "0.05" 40 | 41 | SpotMinMemoryMiB: 42 | Type: Number 43 | Description: "Spot: the minimum desired memory for your instance. On Demand: Ignored" 44 | Default: 2048 45 | 46 | SpotMinVCpuCount: 47 | Type: Number 48 | Description: "Spot: the minimum desired VCPUs for your instance. On Demand: Ignored" 49 | Default: 2 50 | 51 | KeyPairName: 52 | Type: String 53 | Description: (Optional - An empty value disables this feature) 54 | Default: '' 55 | 56 | YourIp: 57 | Type: String 58 | Description: (Optional - An empty value disables this feature) 59 | Default: '' 60 | 61 | HostedZoneId: 62 | Type: String 63 | Description: (Optional - An empty value disables this feature) If you have a hosted zone in Route 53 and wish to set a DNS record whenever your Factorio instance starts, supply the hosted zone ID here. 64 | Default: '' 65 | 66 | RecordName: 67 | Type: String 68 | Description: (Optional - An empty value disables this feature) If you have a hosted zone in Route 53 and wish to set a DNS record whenever your Factorio instance starts, supply the name of the record here (e.g. factorio.mydomain.com). 69 | Default: '' 70 | 71 | EnableRcon: 72 | Type: String 73 | Description: Refer to https://hub.docker.com/r/factoriotools/factorio/ for further RCON configuration details. This parameter simply opens / closes the port on the security group. 74 | Default: false 75 | AllowedValues: 76 | - true 77 | - false 78 | 79 | DlcSpaceAge: 80 | Type: String 81 | Description: Refer to https://hub.docker.com/r/factoriotools/factorio/ for further information about Space Age. Enables or disable Space Age mods. Everybody that wants to use these servers will have to have mods enabled or disabled respectively for the Space Age expansion pack. Irrelevant if docker image for factorio is set to be prior to v2. 82 | Default: false 83 | AllowedValues: 84 | - true 85 | - false 86 | 87 | UpdateModsOnStart: 88 | Type: String 89 | Description: Refer to https://hub.docker.com/r/factoriotools/factorio/ for further configuration details. 90 | Default: false 91 | AllowedValues: 92 | - true 93 | - false 94 | 95 | Metadata: 96 | AWS::CloudFormation::Interface: 97 | ParameterGroups: 98 | - Label: 99 | default: Essential Configuration 100 | Parameters: 101 | - FactorioImageTag 102 | - DlcSpaceAge 103 | - ServerState 104 | - EnableRcon 105 | - UpdateModsOnStart 106 | - Label: 107 | default: Instance Configuration 108 | Parameters: 109 | - InstancePurchaseMode 110 | - InstanceType 111 | - SpotPrice 112 | - SpotMinMemoryMiB 113 | - SpotMinVCpuCount 114 | - Label: 115 | default: Remote Access (SSH) Configuration (Optional) 116 | Parameters: 117 | - KeyPairName 118 | - YourIp 119 | - Label: 120 | default: DNS Configuration (Optional) 121 | Parameters: 122 | - HostedZoneId 123 | - RecordName 124 | ParameterLabels: 125 | FactorioImageTag: 126 | default: "Which version of Factorio do you want to launch?" 127 | DlcSpaceAge: 128 | default: "Do you want everybody that connects to be using the Spage Age Expansion or not?" 129 | ServerState: 130 | default: "Update this parameter to shut down / start up your Factorio server as required to save on cost. Takes a few minutes to take effect." 131 | InstanceType: 132 | default: "Which instance type? You must make sure this is available in your region! https://aws.amazon.com/ec2/pricing/on-demand/" 133 | KeyPairName: 134 | default: "If you wish to access the instance via SSH, select a Key Pair to use. https://console.aws.amazon.com/ec2/v2/home?#KeyPairs:sort=keyName" 135 | YourIp: 136 | default: "If you wish to access the instance via SSH, provide your public IP address." 137 | HostedZoneId: 138 | default: "If you have a hosted zone in Route 53 and wish to update a DNS record whenever your Factorio instance starts, supply the hosted zone ID here." 139 | RecordName: 140 | default: "If you have a hosted zone in Route 53 and wish to set a DNS record whenever your Factorio instance starts, supply a record name here (e.g. factorio.mydomain.com)." 141 | EnableRcon: 142 | default: "Do you wish to enable RCON?" 143 | UpdateModsOnStart: 144 | default: "Do you wish to update your mods on container start" 145 | Conditions: 146 | KeyPairNameProvided: !Not [ !Equals [ !Ref KeyPairName, '' ] ] 147 | IpAddressProvided: !Not [ !Equals [ !Ref YourIp, '' ] ] 148 | DnsConfigEnabled: !And [ !Not [ !Equals [ !Ref HostedZoneId, '' ] ], !Not [ !Equals [ !Ref RecordName, '' ] ] ] 149 | DoEnableRcon: !Equals [ !Ref EnableRcon, 'true' ] 150 | UsingSpotInstance: !Equals [ !Ref InstancePurchaseMode, 'Spot' ] 151 | InstanceTypeProvided: !Not [ !Equals [ !Ref InstanceType, '' ] ] 152 | 153 | Mappings: 154 | ServerState: 155 | Running: 156 | DesiredCapacity: 1 157 | Stopped: 158 | DesiredCapacity: 0 159 | 160 | Resources: 161 | 162 | # ==================================================== 163 | # BASIC VPC 164 | # ==================================================== 165 | 166 | Vpc: 167 | Type: AWS::EC2::VPC 168 | Properties: 169 | CidrBlock: 10.100.0.0/26 170 | EnableDnsSupport: true 171 | EnableDnsHostnames: true 172 | 173 | SubnetA: 174 | Type: AWS::EC2::Subnet 175 | Properties: 176 | AvailabilityZone: !Select 177 | - 0 178 | - !GetAZs 179 | Ref: 'AWS::Region' 180 | CidrBlock: !Select [ 0, !Cidr [ 10.100.0.0/26, 4, 4 ] ] 181 | VpcId: !Ref Vpc 182 | MapPublicIpOnLaunch: true 183 | 184 | SubnetARoute: 185 | Type: AWS::EC2::SubnetRouteTableAssociation 186 | Properties: 187 | RouteTableId: !Ref RouteTable 188 | SubnetId: !Ref SubnetA 189 | 190 | SubnetBRoute: 191 | Type: AWS::EC2::SubnetRouteTableAssociation 192 | Properties: 193 | RouteTableId: !Ref RouteTable 194 | SubnetId: !Ref SubnetB 195 | 196 | SubnetB: 197 | Type: AWS::EC2::Subnet 198 | Properties: 199 | AvailabilityZone: !Select 200 | - 1 201 | - !GetAZs 202 | Ref: 'AWS::Region' 203 | CidrBlock: !Select [ 1, !Cidr [ 10.100.0.0/26, 4, 4 ] ] 204 | VpcId: !Ref Vpc 205 | MapPublicIpOnLaunch: true 206 | 207 | InternetGateway: 208 | Type: AWS::EC2::InternetGateway 209 | Properties: {} 210 | 211 | InternetGatewayAttachment: 212 | Type: AWS::EC2::VPCGatewayAttachment 213 | Properties: 214 | InternetGatewayId: !Ref InternetGateway 215 | VpcId: !Ref Vpc 216 | 217 | RouteTable: 218 | Type: AWS::EC2::RouteTable 219 | Properties: 220 | VpcId: !Ref Vpc 221 | 222 | Route: 223 | Type: AWS::EC2::Route 224 | Properties: 225 | DestinationCidrBlock: 0.0.0.0/0 226 | GatewayId: !Ref InternetGateway 227 | RouteTableId: !Ref RouteTable 228 | 229 | # ==================================================== 230 | # EFS FOR PERSISTENT DATA 231 | # ==================================================== 232 | 233 | Efs: 234 | Type: AWS::EFS::FileSystem 235 | DeletionPolicy: Retain 236 | Properties: 237 | LifecyclePolicies: 238 | - TransitionToIA: AFTER_7_DAYS 239 | - TransitionToPrimaryStorageClass: AFTER_1_ACCESS 240 | 241 | MountA: 242 | Type: AWS::EFS::MountTarget 243 | Properties: 244 | FileSystemId: !Ref Efs 245 | SecurityGroups: 246 | - !Ref EfsSg 247 | SubnetId: !Ref SubnetA 248 | 249 | MountB: 250 | Type: AWS::EFS::MountTarget 251 | Properties: 252 | FileSystemId: !Ref Efs 253 | SecurityGroups: 254 | - !Ref EfsSg 255 | SubnetId: !Ref SubnetB 256 | 257 | EfsSg: 258 | Type: AWS::EC2::SecurityGroup 259 | Properties: 260 | GroupName: !Sub "${AWS::StackName}-efs" 261 | GroupDescription: !Sub "${AWS::StackName}-efs" 262 | SecurityGroupIngress: 263 | - FromPort: 2049 264 | ToPort: 2049 265 | IpProtocol: tcp 266 | SourceSecurityGroupId: !Ref Ec2Sg 267 | VpcId: !Ref Vpc 268 | 269 | # ==================================================== 270 | # INSTANCE CONFIG 271 | # ==================================================== 272 | 273 | Ec2Sg: 274 | Type: AWS::EC2::SecurityGroup 275 | Properties: 276 | GroupName: !Sub "${AWS::StackName}-ec2" 277 | GroupDescription: !Sub "${AWS::StackName}-ec2" 278 | SecurityGroupIngress: 279 | - !If 280 | - IpAddressProvided 281 | - FromPort: 22 282 | ToPort: 22 283 | IpProtocol: tcp 284 | CidrIp: !Sub "${YourIp}/32" 285 | - !Ref 'AWS::NoValue' 286 | - FromPort: 34197 287 | ToPort: 34197 288 | IpProtocol: udp 289 | CidrIp: 0.0.0.0/0 290 | - !If 291 | - DoEnableRcon 292 | - FromPort: 27015 293 | ToPort: 27015 294 | IpProtocol: tcp 295 | CidrIp: 0.0.0.0/0 296 | - !Ref 'AWS::NoValue' 297 | VpcId: !Ref Vpc 298 | 299 | LaunchTemplate: 300 | Type: AWS::EC2::LaunchTemplate 301 | Properties: 302 | LaunchTemplateName: !Sub ${AWS::StackName}-launch-template 303 | LaunchTemplateData: 304 | IamInstanceProfile: 305 | Arn: !GetAtt InstanceProfile.Arn 306 | ImageId: !Ref ECSAMI 307 | SecurityGroupIds: 308 | - !Ref Ec2Sg 309 | KeyName: 310 | !If [ KeyPairNameProvided, !Ref KeyPairName, !Ref 'AWS::NoValue' ] 311 | UserData: 312 | Fn::Base64: !Sub | 313 | #!/bin/bash -xe 314 | echo ECS_CLUSTER=${EcsCluster} >> /etc/ecs/ecs.config 315 | 316 | # Only run DNS update if DNS is enabled 317 | if [ "${HostedZoneId}" != "" ] && [ "${RecordName}" != "" ]; then 318 | # Install AWS CLI 319 | yum install -y aws-cli 320 | # Get instance ID and public IP 321 | PUBLIC_IP=$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4) 322 | 323 | # Update Route53 DNS record 324 | aws route53 change-resource-record-sets \ 325 | --hosted-zone-id ${HostedZoneId} \ 326 | --change-batch '{ 327 | "Changes": [{ 328 | "Action": "UPSERT", 329 | "ResourceRecordSet": { 330 | "Name": "${RecordName}", 331 | "Type": "A", 332 | "TTL": 60, 333 | "ResourceRecords": [{"Value":"'$PUBLIC_IP'"}] 334 | } 335 | }] 336 | }' \ 337 | --region ${AWS::Region} 338 | fi 339 | 340 | AutoScalingGroup: 341 | Type: AWS::AutoScaling::AutoScalingGroup 342 | Properties: 343 | AutoScalingGroupName: !Sub "${AWS::StackName}-asg" 344 | DesiredCapacity: !FindInMap [ ServerState, !Ref ServerState, DesiredCapacity ] 345 | MixedInstancesPolicy: 346 | InstancesDistribution: 347 | OnDemandPercentageAboveBaseCapacity: 348 | !If [ UsingSpotInstance, 0, 100 ] 349 | SpotAllocationStrategy: lowest-price 350 | SpotMaxPrice: 351 | !If [ UsingSpotInstance, !Ref SpotPrice, !Ref AWS::NoValue ] 352 | LaunchTemplate: 353 | LaunchTemplateSpecification: 354 | LaunchTemplateId: !Ref LaunchTemplate 355 | Version: !GetAtt LaunchTemplate.LatestVersionNumber 356 | Overrides: 357 | - Fn::If: 358 | - InstanceTypeProvided 359 | - InstanceType: !Ref InstanceType 360 | - InstanceRequirements: 361 | MemoryMiB: 362 | Min: !Ref SpotMinMemoryMiB 363 | VCpuCount: 364 | Min: !Ref SpotMinVCpuCount 365 | MaxSize: !FindInMap [ ServerState, !Ref ServerState, DesiredCapacity ] 366 | MinSize: !FindInMap [ ServerState, !Ref ServerState, DesiredCapacity ] 367 | VPCZoneIdentifier: 368 | - !Ref SubnetA 369 | - !Ref SubnetB 370 | 371 | InstanceRole: 372 | Type: AWS::IAM::Role 373 | Properties: 374 | AssumeRolePolicyDocument: 375 | Version: '2012-10-17' 376 | Statement: 377 | - Effect: Allow 378 | Principal: 379 | Service: 380 | - ec2.amazonaws.com 381 | Action: 382 | - sts:AssumeRole 383 | ManagedPolicyArns: 384 | - arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role 385 | Policies: 386 | - PolicyName: root 387 | PolicyDocument: 388 | Version: "2012-10-17" 389 | Statement: 390 | - Effect: "Allow" 391 | Action: "route53:*" 392 | Resource: "*" 393 | 394 | InstanceProfile: 395 | Type: AWS::IAM::InstanceProfile 396 | Properties: 397 | Roles: 398 | - !Ref InstanceRole 399 | 400 | EcsCluster: 401 | Type: AWS::ECS::Cluster 402 | Properties: 403 | ClusterName: !Sub "${AWS::StackName}-cluster" 404 | 405 | EcsService: 406 | Type: AWS::ECS::Service 407 | Properties: 408 | Cluster: !Ref EcsCluster 409 | DesiredCount: !FindInMap [ ServerState, !Ref ServerState, DesiredCapacity ] 410 | ServiceName: !Sub "${AWS::StackName}-ecs-service" 411 | TaskDefinition: !Ref EcsTask 412 | DeploymentConfiguration: 413 | MaximumPercent: 100 414 | MinimumHealthyPercent: 0 415 | 416 | EcsTask: 417 | Type: AWS::ECS::TaskDefinition 418 | DependsOn: 419 | - MountA 420 | - MountB 421 | Properties: 422 | Volumes: 423 | - Name: factorio 424 | EFSVolumeConfiguration: 425 | FilesystemId: !Ref Efs 426 | TransitEncryption: ENABLED 427 | ContainerDefinitions: 428 | - Name: factorio 429 | MemoryReservation: 1024 430 | Image: !Sub "factoriotools/factorio:${FactorioImageTag}" 431 | PortMappings: 432 | - ContainerPort: 34197 433 | HostPort: 34197 434 | Protocol: udp 435 | - ContainerPort: 27015 436 | HostPort: 27015 437 | Protocol: tcp 438 | MountPoints: 439 | - ContainerPath: /factorio 440 | SourceVolume: factorio 441 | ReadOnly: false 442 | Environment: 443 | - Name: UPDATE_MODS_ON_START 444 | Value: !Sub "${UpdateModsOnStart}" 445 | - Name: DLC_SPACE_AGE 446 | Value: !Sub "${DlcSpaceAge}" 447 | 448 | Outputs: 449 | CheckInstanceIp: 450 | Description: To find your Factorio instance IP address, visit the following link. Click on the instance to find its Public IP address. 451 | Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/ec2/v2/home?region=${AWS::Region}#Instances:tag:aws:autoscaling:groupName=${AutoScalingGroup};sort=tag:Name" 452 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Complete Factorio Server Deployment (CloudFormation) 2 | 3 | The template contained within this repository can be used to deploy a Factorio server to Amazon Web Services (AWS) in minutes. As the solution leverages "Spot Pricing", the server should cost less than a cent an hour to run, and you can even turn it off when you and your friends aren't playing - saving even more money. 4 | 5 | If you wish to deploy multiple factorio servers with the one CloudFormation template (perhaps because of a team event or you just have different saves that you want to be able to pick and choose from), then a multi-server fork of this repository exists here: https://github.com/robertmassaioli/factorio-multi-server-spot-pricing 6 | 7 | ## Prerequisites 8 | 9 | 1. A basic understanding of Amazon Web Services. 10 | 2. An AWS Account. 11 | 3. Basic knowledge of Linux administration (no more than what would be required to just use the `factoriotools/factorio` Docker image). 12 | 13 | ## Overview 14 | 15 | The solution builds upon the [factoriotools/factorio](https://hub.docker.com/r/factoriotools/factorio) Docker image, so generously curated by [the folks over at FactorioTools](https://github.com/orgs/factoriotools/people) (thank you!). 16 | 17 | In a nutshell, the CloudFormation template launches an _ephemeral_ instance which joins itself to an Elastic Container Service (ECS) Cluster. Within this ECS Cluster, an ECS Service is configured to run a Factorio Docker image. The ephemeral instance does not store any saves, mods, Factorio config, data etc. - all of this state is stored on a network file system (Elastic File System - EFS). 18 | 19 | The CloudFormation template is configured to launch this ephemeral instance using spot pricing. What is spot pricing you might ask? It's a way to save up to 90% on regular "on demand" pricing in AWS. There are drawbacks however. You're effectively participating in an auction to get a cheap instance. If demand increases and someone else puts in a higher bid than you, your instance will terminate in a matter of minutes. 20 | 21 | A few notes on the services we're using... 22 | 23 | * **EFS** - Elastic File System is used to store Factorio config, save games, mods etc. None of this is stored on the server itself, as it may terminate at any time. 24 | * **Auto Scaling** - An Auto Scaling Group is used to maintain a single instance via spot pricing. 25 | * **VPC** - The template deploys a very basic VPC, purely for use by the Factorio server. This doesn't cost you a cent. 26 | 27 | ## Getting Started 28 | 29 | [![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home#/stacks/new?stackName=factorio&templateURL=https://s3.amazonaws.com/factorio-spot-pricing/cf.yml) 30 | 31 | 1. Click the above link, you'll need to log into your AWS account if you haven't already. 32 | 2. Ensure you've selected a suitable AWS Region (closest to you) via the selector at the top right. 33 | 3. Click Next to proceed through the CloudFormation deployment, provide parameters on the following page. You'll need a Key Pair and your Public IP address if you want to access the instance remotely via SSH (recommended). Refer to the Remote Access section below. There should be no need to touch any other parameters unless you have reason to do so. Continue through the rest of the deployment. 34 | 35 | ## On Demand vs Spot 36 | 37 | You may switch between On Demand / Spot via the InstancePurchaseMode CloudFormation parameter. When using Spot, it is not necessary to specify an InstanceType. Simply adjust the SpotMinMemoryMiB and SpotMinVCPUCount to specify how much Memory and CPU you would like on your instance. AWS will find you the cheapest spot instance available below the SpotPrice that you have specified. Should you wish to use a specific instance, you can specify it via the InstanceType parameter. If you are using "On Demand", you *must* specify the InstanceType. 38 | 39 | ## Next Steps 40 | 41 | All things going well, your Factorio server should be running in five minutes or so. Wait until CloudFormation reports the stack status as `CREATE_COMPLETE`. Go to the [EC2 dashboard in the AWS console](https://console.aws.amazon.com/ec2/v2/home?#Instances:sort=instanceId) and you should see a Factorio server running. Take note of the public IP address. You should be able to fire up Factorio, and join via this IP address. No need to provide a port number, we're using Factorio's default. *Bonus points* - Public IP addresses are ugly. Refer to Custom Domain Name within Optional Features for a better solution. 42 | 43 | At this point you should *really* configure remote access as per the below section, so that you can access the server and modify `server-settings.json` (e.g. add a password, add to the Factorio server browser, whitelist admins etc.). 44 | 45 | ## Optional Features 46 | 47 | ### Remote Access 48 | 49 | You will likely want to SSH onto the Linux instance to make server changes / add a game password. You might also want to do this to upload your existing save. For security, SSH should be locked down to a known IP address (i.e. you), preventing malicious users from trying to break in. You'll need to create a Key Pair in AWS, find your public IP address, and then provide both of the parameters in the Remote Access (SSH) Configuration (Optional) section. 50 | 51 | Note that this assumes some familiarity with SSH. The Linux instance will have a user `ec2-user` which you may connect to via SSH. If you want to upload saves, it's easiest to upload them to `/home/ec2-user` via SCP as the `ec2-user` user (this is `ec2-user`'s home directory), and then `sudo mv` these files to the right location in the factorio installation via SSH. 52 | 53 | For remote access, you'll need to: 54 | 55 | 1. Create a [Key Pair](https://console.aws.amazon.com/ec2/v2/home#KeyPairs:sort=keyName) (Services > EC2 > Key Pairs). You'll need to use this to connect to the instance for additional setup. 56 | 2. [Find your public IP address]((https://whatismyipaddress.com/)). You'll need this to connect to the instance for additional setup. 57 | 58 | If you're creating a new Factorio deployment, provide these parameters when creating the stack. Otherwise, update your existing stack and provide these parameters. 59 | 60 | #### Uploading and Downloading an existing save. 61 | 62 | ##### Fast save upload (Recommended) 63 | 64 | Warning: Makes sure that your server is live and all EC2 and ECS healthchecks are green before trying this. 65 | 66 | Use the automation in `util/upload-save.bash` to upload your save file to your server, like so: 67 | 68 | ``` bash 69 | bash util/upload-save.bash ~/path/to/my/save.zip $your_ec2_ip_or_remote_name 70 | ``` 71 | 72 | Optionally, you can specify a path to a private key with a bash variable rather than relying on default ssh keys. 73 | 74 | ``` bash 75 | FACTORIO_PEM=~/path/to/my.pem bash util/upload-save.bash ~/path/to/my/save.zip $your_ec2_ip_or_remote_name 76 | ``` 77 | 78 | This is just an automated implementation of the slower version below. 79 | 80 | ##### Fast save download (Recommended) 81 | 82 | Use the automation in `util/download-latest-save.bash` to download the latest (most recently played) save from a factorio server: 83 | 84 | ``` bash 85 | bash util/download-latest-save.bash $your_ec2_ip_or_remote_name 86 | ``` 87 | 88 | Optionally, you can specify a path to a private key with a bash variable rather than relying on default ssh keys. 89 | 90 | ``` bash 91 | FACTORIO_PEM=~/path/to/my.pem bash util/download-latest-save.bash $your_ec2_ip_or_remote_name 92 | ``` 93 | 94 | Your server needs to be running for this to work and it should download your latest save to your local directory. 95 | 96 | ##### Manual upload process (for understanding the system) 97 | 98 | This procedure involves uploading your new save and then force killing the docker container. When the container is force killed it won't auto save, and the default logic is that on restart, the latest save will be loaded. To do this you must have SSH enabled via the CloudFormation deployment. The container must be running, otherwise you can't access EFS (where the save resides) from the EC2 instance. 99 | 100 | 1. From your computer, upload your save to the EC2 instance. 101 | `scp MySave.zip ec2-user@:~/` 102 | 103 | 2. SSH into the EC2 instance. 104 | `ssh ec2-user@` 105 | 106 | 3. Identify the first 3 digits of the factorio docker container ID. We're doing this in advance, as we need to be quick in later steps. 107 | `docker ps` 108 | 109 | Output will look something like this. The container ID is 19d3e1743e5c, so just note down 19d for future use. 110 | ``` 111 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 112 | 19d3e1743e5c factoriotools/factorio:stable "/docker-entrypoint.…" 2 minutes ago Up 2 minutes 0.0.0.0:27015->27015/tcp, :::27015->27015/tcp, 0.0.0.0:34197->34197/udp, :::34197->34197/udp ecs-factorio-EcsTask-i4J15601Hvkr-1-factorio-f6e3809ad5d6d8848501 113 | 01c68c702f42 amazon/amazon-ecs-agent:latest "/agent" 27 minutes ago Up 26 minutes (healthy) ecs-agent 114 | ``` 115 | 116 | 4. Find the save directory with the below command. 117 | `savedir=$(mount | grep nfs4 | cut -f3 -d ' ' | xargs -I {} echo "{}/saves")` 118 | 119 | 5. Move your uploaded save to the right location. 120 | `sudo mv ~/MySave.zip $savedir` 121 | 122 | 6. Touch your save to ensure it's timestamp is the latest out of all saves. If you take too long between this step and the next (and the server auto-saves), it will instead load the auto save. If this happens you got very unlucky... just try again. 123 | `touch $savedir/MySave.zip` 124 | 125 | 7. Force kill the factorio docker container. It must be killed and not stopped, otherwise it will auto-save and load that save on restart. Use part of the container ID we noted before. 126 | `docker kill 19d` 127 | 128 | 8. That should be it. Wait 30s for the container to restart, and when you connect it should load the save you just uploaded. 129 | 130 | ### Custom Domain Name 131 | 132 | Every time your Factorio server starts it'll have a new public IP address. This can be a pain to keep dishing out to your friends. If you're prepared to register a domain name (maybe you've already got one) and create a Route 53 hosted zone, this problem is easily fixed. You'll need to provide both of the parameters under the DNS Configuration (Optional) section. Whenever your instance is launched, a Lambda function fires off and creates / updates the record of your choosing. This way, you can have a custom domain name such as "factorio.mydomain.com". Note that it may take a few minutes for the new IP to propagate to your friends computers. Have patience. Failing that just go to the EC2 console, and give them the new public IP address of your instance. 133 | 134 | ## FAQ 135 | 136 | **Do I need a VPC, or Subnets, or other networking config in AWS?** 137 | 138 | Nope. The stack creates everything you need. 139 | 140 | **What if my server is terminated due to my Spot Request being outbid?** 141 | 142 | Everything is stored on EFS, so don't worry you won't lose anything (well, that's partially true - you might lose up to 5 minutes of gameplay depending on when the server last saved). There is every chance your instance will come back in a few minutes. If not you can either select a different instance type, increase your spot price, or completely disable spot pricing and revert to on demand pricing. All of these options can be performed by updating your CloudFormation stack parameters. 143 | 144 | **My server keeps getting terminated. I don't like Spot Pricing. Take me back to the good old days.** 145 | 146 | That's fine; update your CloudFormation stack and set the SpotPrice parameter to an empty value. Voila, you'll now be using On Demand pricing (and paying significantly more). 147 | 148 | **How do I change my instance type?** 149 | 150 | Update your CloudFormation stack. Enter a different instance type. 151 | 152 | **What is the best instance type to run Factorio** 153 | 154 | For the source of truth, we can look at the [minimum/recommended game settings](https://store.steampowered.com/app/645390/Factorio_Space_Age/) for Factorio which says: 155 | 156 | * Processor Speed: 3Ghz+ minimum / 4Ghz+ recommended 157 | * Processor Cores: Quad Core 158 | * Memory: 8GB minimum / 16GB recommended 159 | 160 | Given that, we can use a handy tool, [like Cloud Price](https://cloudprice.net/aws/ec2?_ProcessorVCPUCount_min=2&_ProcessorVCPUCount_max=4&columns=InstanceType,InstanceFamily,ProcessorVCPUCount,MemorySizeInMB,ProcessorArchitecture,HasGPU,PricePerHour,ProcessorSustainedClockSpeedInGHz,__AlternativeInstances,__SavingsOptions,BestOnDemandHourPriceDiff&sortField=ProcessorSustainedClockSpeedInGHz&sortOrder=false&paymentType=Spot) to find us the cheapest instances that also meet these requirements and sort them by Clock Speed. 161 | 162 | At the time of writing this would indicate that the best instances are: 163 | 164 | * For Minimum Spec: m6a.large (2vCPUs, 8GB, 3.6Ghz) => USD$0.0317/hour spot 165 | * For Mid-Range Spec: m5zn.large (2vCPUs, 8GB, 4.5Ghz) => USD$0.0582/hour spot 166 | * For High-Range Spec: m5zn.xlarge (4vCPUs, 16GB, 4.5Ghz) => USD$0.1787/hour spot 167 | 168 | It is recommended that you start off on minimum spec and then, when you notice that you need more power, stop your server, swap instance type, and start again. This is one of the best benefits of factorio spot pricing, being able to get a better computer to run your game at a moment's notice. 169 | 170 | **How do I change my spot price limit?** 171 | 172 | Update your CloudFormation stack. Enter a different limit. 173 | 174 | **I'm done for the night / week / month / year. How do I turn off my Factorio server?** 175 | 176 | Update your CloudFormation stack. Change the server state parameter from "Running" to "Stopped". 177 | 178 | **How do I turn my stack on and off from the terminal?** 179 | 180 | You can write a bash script, using the CLI like so: 181 | 182 | ``` bash 183 | #!/bin/bash 184 | 185 | update_stack() { 186 | local state=$1 187 | aws cloudformation update-stack \ 188 | --stack-name factorio-2024 \ 189 | --use-previous-template \ 190 | --parameters ParameterKey=ServerState,ParameterValue=$state \ 191 | --capabilities CAPABILITY_IAM \ 192 | --profile AdministratorAccess-111111111111 193 | } 194 | 195 | case "$1" in 196 | start) 197 | update_stack "Running" 198 | ;; 199 | stop) 200 | update_stack "Stopped" 201 | ;; 202 | *) 203 | echo "Usage: $0 {start|stop}" 204 | exit 1 205 | ;; 206 | esac 207 | ``` 208 | 209 | If you put that in a file called `update_factorio.bash` then you could run: 210 | 211 | ``` bash 212 | $ bash update_factorio.bash 213 | ``` 214 | 215 | That does require that you run `aws configure sso` first and set up an IAM account with all the right perms. 216 | 217 | **I'm done with Factorio, how do I delete this server?** 218 | 219 | Delete the CloudFormation stack. Except for the EFS, Done. The EFS is retained when the CloudFormation stack is deleted to preserve your saves, but can then be manually deleted. 220 | 221 | **How can I upgrade the Factorio version?** 222 | 223 | Update your CloudFormation stack. Set the required tag value for `FactorioImageTag`. Use the tags specified here: https://hub.docker.com/r/factoriotools/factorio/. Your Factorio server will stop momentarily. 224 | 225 | **I'm running the "latest" version, and a new version has just been released. How do I update my server?** 226 | 227 | You can force a redeployment of the service via ECS. [Update the service](https://console.aws.amazon.com/ecs/home?#/clusters/factorio/services/factorio/update), and select `Force new deployment`. 228 | 229 | **How can I change map settings, server settings etc.** 230 | 231 | You'll need to have remote access to the server (refer to Optional Features). You can make whatever changes you want to the configuration in `/opt/factorio/config`. Once done, restart the container: 232 | 233 | 1. Go to ECS (Elastic Container Service) in the AWS Console 234 | 2. Click the factorio cluster 235 | 3. Tick the factorio service, and select update 236 | 4. Tick "Force new deployment" 237 | 5. Click Next 3 times, and finally Update service 238 | 239 | **I can no longer connect to my instance via SSH?** 240 | 241 | Your public IP address has probably changed. [Check your public IP address]((https://whatismyipaddress.com/)) and update the stack, providing your new public IP address. 242 | 243 | **How do I load an existing save?** 244 | 245 | Be advised that whenever the Factorio container is terminated, it creates a new autosave just prior to terminating. For this reason, restarting the container directly on the host via SSH isn't advised. 246 | 247 | In order to load an existing save, follow the below steps: 248 | 249 | 1. Go to ECS (Elastic Container Service) in the AWS Console 250 | 2. Click the factorio cluster 251 | 3. Tick the factorio service, and select update 252 | 4. Set Number of tasks to 0 253 | 5. Click Next 3 times, and finally Update service 254 | 6. Access the instance via SSH, placing your save in /opt/factorio/saves. 255 | 7. Repeat steps above, setting the Number of tasks to 1. 256 | 257 | ## What's Missing / Not Supported? 258 | 259 | * Scenarios - you can probably figure out a way to get this working... I've just never tried :-). 260 | 261 | ## Expected Costs 262 | 263 | The two key components that will attract charges are: 264 | 265 | * **EC2** - If you're using spot pricing (and the m3.medium instance as per the default in the template), I doubt you would attract more than a cent an hour in fees for EC2. Even if you ran it 24 hours a day for a whole month, that's about 7 bucks. 266 | * **EFS** - Charged per Gigabyte stored per month (GB-Month). Varies based on region, but typically less than 50c per gigabyte. My EFS file system for Factorio is only about 100MB (incl. mods and 5 saves), so maybe 5 cents per month? To lower storage costs when not actively utilzied, items in EFS are automatically moved to Infrequent Access after 7 days and also moved back to Standard if subsequently accessed. 267 | 268 | AWS do charge for data egress (i.e. data being sent from your Factorio server to clients), but again this should be barely noticeable. 269 | 270 | ## Help / Support 271 | 272 | This has been tested in both the Sydney and Oregon AWS regions (verify your AWS region of choice includes m3.medium @ https://aws.amazon.com/ec2/spot/pricing and/or change as needed). Your mileage may vary. If you get stuck, create an issue and myself or someone else may come along and assist. 273 | 274 | Be sure to check out factoriotools's repositories on Docker Hub and GitHub. Unless your question is specifically related to the AWS deployment, you may find the information you're after there: 275 | 276 | - Docker Hub: https://hub.docker.com/r/factoriotools/factorio/ 277 | - GitHub: https://github.com/factoriotools/factorio-docker 278 | 279 | ### Stack gets stuck on CREATE_IN_PROGRESS 280 | 281 | There might be multiple reasons. 282 | 283 | #### Selected Instance Type not available in your region 284 | 285 | NOTE: This should no longer be an issue when using the SpotMinMemoryMiB and SpotMinVCPUCount parameters instead of InstanceType. 286 | 287 | It may be because there are no suitable Instance Types available in your region. As a result of this, an auto-scaling group gets successfully created - however it never launches an instance. This means the ECS service cannot ever start, as it has nowhere to place the container. I would suggest going to the AWS Console > EC2 > Spot Requests > Pricing History, and find a suitable instance type that's cost effective and has little to no fluctuation in price. 288 | 289 | In the below example (Paris), `m5.large` looks like a good option. Try to create the CloudFormation stack again, changing the InstanceType CloudFormation parameter to `m5.large`. See: https://github.com/m-chandler/factorio-spot-pricing/issues/10 290 | 291 | ![Spot pricing history](readme-spot-pricing.jpg?raw=true) 292 | 293 | 294 | ### Restarting the Container 295 | 296 | Visit the ECS dashboard in the AWS Console. 297 | 1. Clusters 298 | 2. Click on the factorio Cluster 299 | 3. Tick the factorio Service, click Update 300 | 4. Tick the "Force new deployment" option 301 | 5. Click Next step (three times) 302 | 7. Click Update Service 303 | 304 | ### Basic Docker Debugging 305 | 306 | If you SSH onto the server, you can run the following commands for debugging purposes: 307 | 308 | * `sudo docker logs $(docker ps -q --filter ancestor=factoriotools/factorio)` - Check Factorio container logs. 309 | 310 | DO NOT restart the Factorio docker container via SSH. This will cause ECS to lose track of the container, and effectively kill the restarted container and create a new one. Refer to Restarting the Container above for the right method. 311 | 312 | ## Changelog 313 | 314 | 06-Oct-2024 315 | * Migrate from Launch Configuration to Launch Template, as Launch Configuration is unavailable in AWS accounts created after 01-Oct-2024. 316 | * Remove requirement for user to specify an instance type, but rather specify the Memory and vCPU that they require. AWS will figure out the best instance type. 317 | 318 | ## Thanks 319 | 320 | Thanks goes out to [FactorioTools](https://github.com/factoriotools) ([and contributors](https://github.com/factoriotools/factorio-docker/graphs/contributors)) for maintaining the Factorio Docker images. 321 | 322 | Thank you to all who have contributed to this repository. --------------------------------------------------------------------------------