diff --git a/keyserver/src/user/checks.js b/keyserver/src/user/checks.js
--- a/keyserver/src/user/checks.js
+++ b/keyserver/src/user/checks.js
@@ -1,6 +1,9 @@
 // @flow
 
 import { getCommConfig } from 'lib/utils/comm-config.js';
+
+// changes here should be reflected for keyserver_user_credentials in
+// services/terraform/self-host/variables.tf
 export type UserCredentials = {
   +username: string,
   +password: string,
diff --git a/services/terraform/self-host/aws_db.tf b/services/terraform/self-host/aws_db.tf
--- a/services/terraform/self-host/aws_db.tf
+++ b/services/terraform/self-host/aws_db.tf
@@ -5,6 +5,13 @@
   vpc_id      = local.vpc_id
 
   # Inbound rules
+  ingress {
+    from_port       = 3307
+    to_port         = 3307
+    protocol        = "tcp"
+    security_groups = [aws_security_group.keyserver_service.id]
+  }
+
   ingress {
     from_port   = 3307
     to_port     = 3307
diff --git a/services/terraform/self-host/aws_ecs.tf b/services/terraform/self-host/aws_ecs.tf
new file mode 100644
--- /dev/null
+++ b/services/terraform/self-host/aws_ecs.tf
@@ -0,0 +1,23 @@
+resource "aws_ecs_cluster" "keyserver_cluster" {
+  name = "keyserver-cluster"
+
+  configuration {
+    execute_command_configuration {
+      logging = "DEFAULT"
+    }
+  }
+}
+
+# Namespace for services to be able to communicate with each other
+# by their hostnames. Similar to docker compose network.
+resource "aws_service_discovery_http_namespace" "keyserver_cluster" {
+  name = "keyserver-cluster-http-namespace"
+  tags = {
+    "AmazonECSManaged" = "true"
+  }
+}
+
+resource "aws_ecs_cluster_capacity_providers" "keyserver_cluster" {
+  cluster_name       = aws_ecs_cluster.keyserver_cluster.name
+  capacity_providers = ["FARGATE"]
+}
diff --git a/services/terraform/self-host/aws_iam.tf b/services/terraform/self-host/aws_iam.tf
new file mode 100644
--- /dev/null
+++ b/services/terraform/self-host/aws_iam.tf
@@ -0,0 +1,87 @@
+resource "aws_iam_role" "ecs_task_role" {
+  name               = "ecs-iam_role"
+  description        = "Allows to SSH into ECS containers"
+  assume_role_policy = data.aws_iam_policy_document.assume_role_ecs_ec2.json
+
+  managed_policy_arns = [
+    aws_iam_policy.allow_ecs_exec.arn,
+  ]
+}
+
+data "aws_iam_policy_document" "assume_role_ecs_ec2" {
+  statement {
+    effect = "Allow"
+    actions = [
+      "sts:AssumeRole",
+    ]
+    principals {
+      type = "Service"
+      identifiers = [
+        "ec2.amazonaws.com",
+        "ecs-tasks.amazonaws.com"
+      ]
+    }
+  }
+}
+
+resource "aws_iam_policy" "allow_ecs_exec" {
+  name        = "allow-ecs-exec"
+  description = "Adds SSM permissions to enable ECS Exec"
+  policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [
+      {
+        Effect = "Allow"
+        Action = [
+          "ssmmessages:CreateControlChannel",
+          "ssmmessages:CreateDataChannel",
+          "ssmmessages:OpenControlChannel",
+          "ssmmessages:OpenDataChannel"
+        ]
+        Resource = "*"
+      }
+    ]
+  })
+}
+
+resource "aws_iam_role" "fargate_execution_role" {
+  assume_role_policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [
+      {
+        Effect = "Allow"
+        Principal = {
+          Service = "ecs-tasks.amazonaws.com"
+        }
+        Action = "sts:AssumeRole"
+      }
+    ]
+  })
+}
+
+resource "aws_iam_role_policy_attachment" "fargate_execution_role" {
+  role       = aws_iam_role.fargate_execution_role.name
+  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
+}
+
+resource "aws_iam_role" "ecs_task_execution" {
+  name = "ecsTaskExecutionRole"
+  assume_role_policy = jsonencode({
+    Version = "2008-10-17"
+    Statement = [
+      {
+        Sid    = ""
+        Action = "sts:AssumeRole"
+        Effect = "Allow"
+        Principal = {
+          Service = "ecs-tasks.amazonaws.com"
+        }
+      }
+    ]
+  })
+
+  managed_policy_arns = [
+    "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
+    "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess",
+  ]
+}
diff --git a/services/terraform/self-host/keyserver_primary.tf b/services/terraform/self-host/keyserver_primary.tf
new file mode 100644
--- /dev/null
+++ b/services/terraform/self-host/keyserver_primary.tf
@@ -0,0 +1,155 @@
+locals {
+  keyserver_service_image_tag      = "0.1"
+  keyserver_service_server_image   = "commapp/keyserver:${local.keyserver_service_image_tag}"
+  keyserver_service_container_name = "keyserver-primary"
+}
+
+resource "aws_cloudwatch_log_group" "ecs_log_group" {
+  name              = "/ecs/keyserver-primary-task-def"
+  retention_in_days = 7
+}
+
+output "mariadb_address" {
+  value = aws_db_instance.mariadb.address
+}
+
+resource "aws_ecs_task_definition" "keyserver_service" {
+  network_mode             = "awsvpc"
+  family                   = "keyserver-primary-task-def"
+  requires_compatibilities = ["FARGATE"]
+  task_role_arn            = aws_iam_role.ecs_task_role.arn
+  execution_role_arn       = aws_iam_role.ecs_task_execution.arn
+  cpu                      = "1024"
+  memory                   = "3072"
+
+  ephemeral_storage {
+    size_in_gib = 40
+  }
+
+  container_definitions = jsonencode([
+    {
+      name      = local.keyserver_service_container_name
+      image     = local.keyserver_service_server_image
+      essential = true
+      portMappings = [
+        {
+          name          = "keyserver-port"
+          containerPort = 3000
+          protocol      = "tcp"
+        },
+        {
+          name          = "http-port"
+          containerPort = 80
+          protocol      = "tcp"
+          appProtocol   = "http"
+        },
+      ]
+      environment = [
+        {
+          name  = "COMM_DATABASE_HOST"
+          value = "${aws_db_instance.mariadb.address}"
+        },
+        {
+          name  = "COMM_DATABASE_DATABASE"
+          value = "comm"
+        },
+        {
+          name  = "COMM_DATABASE_PORT"
+          value = "3307"
+        },
+        {
+          name  = "COMM_DATABASE_USER"
+          value = "${var.mariadb_username}"
+        },
+        {
+          name  = "COMM_DATABASE_PASSWORD"
+          value = "${var.mariadb_password}"
+        },
+        {
+          name  = "COMM_JSONCONFIG_secrets_user_credentials"
+          value = jsonencode(var.keyserver_user_credentials)
+        },
+        {
+          name = "COMM_JSONCONFIG_facts_webapp_cors"
+          value = jsonencode({
+            "domain" : "https://web.comm.app"
+          })
+        },
+        {
+          name = "COMM_JSONCONFIG_secrets_identity_service_config",
+          value = jsonencode({
+            "identitySocketAddr" : "${var.identity_socket_address}"
+          })
+        },
+      ]
+      logConfiguration = {
+        "logDriver" = "awslogs"
+        "options" = {
+          "awslogs-create-group"  = "true"
+          "awslogs-group"         = aws_cloudwatch_log_group.ecs_log_group.name
+          "awslogs-stream-prefix" = "ecs"
+          "awslogs-region"        = "${var.region}"
+        }
+      }
+      linuxParameters = {
+        initProcessEnabled = true
+      }
+    }
+  ])
+
+  runtime_platform {
+    cpu_architecture        = "ARM64"
+    operating_system_family = "LINUX"
+  }
+
+  skip_destroy = false
+}
+
+resource "aws_ecs_service" "keyserver_primary_service" {
+  name                    = "keyserver-primary-service"
+  cluster                 = aws_ecs_cluster.keyserver_cluster.id
+  task_definition         = aws_ecs_task_definition.keyserver_service.arn
+  launch_type             = "FARGATE"
+  enable_execute_command  = true
+  enable_ecs_managed_tags = true
+  force_new_deployment    = true
+  desired_count           = 1
+
+  network_configuration {
+    subnets          = local.vpc_subnets
+    security_groups  = [aws_security_group.keyserver_service.id]
+    assign_public_ip = true
+  }
+
+  deployment_circuit_breaker {
+    enable   = true
+    rollback = true
+  }
+}
+
+resource "aws_security_group" "keyserver_service" {
+  name   = "keyserver-service-ecs-sg"
+  vpc_id = local.vpc_id
+
+  # Allow all inbound traffic. This is temporary until load balancer is configured
+  ingress {
+    from_port   = 0
+    to_port     = 65535
+    protocol    = "tcp"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+
+  # Allow all outbound traffic
+  egress {
+    from_port   = 0
+    to_port     = 0
+    protocol    = "-1"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+
+  lifecycle {
+    create_before_destroy = true
+  }
+}
+
+
diff --git a/services/terraform/self-host/variables.tf b/services/terraform/self-host/variables.tf
--- a/services/terraform/self-host/variables.tf
+++ b/services/terraform/self-host/variables.tf
@@ -1,3 +1,13 @@
+variable "keyserver_user_credentials" {
+  description = "Credentials for user authentication"
+  type = object({
+    username                 = string
+    password                 = string
+    usingIdentityCredentials = optional(bool)
+    force                    = optional(bool)
+  })
+}
+
 variable "mariadb_username" {
   description = "MariaDB username"
   type        = string
@@ -23,8 +33,6 @@
 
 variable "user_created_vpc" {
   description = "Use non-default vpc and subnets"
-  type        = bool
-  default     = false
 }
 
 variable "availability_zone_1" {
@@ -38,3 +46,9 @@
   type        = string
   default     = "us-west-1c"
 }
+
+variable "identity_socket_address" {
+  description = "The socket address to access the identity service"
+  type        = string
+  default     = "https://identity.commtechnologies.org:50054"
+}