DEV Community

Cover image for Building and Deploying TypeScript Microservices to Kubernetes
Marcus Kohlberg for Encore

Posted on

Building and Deploying TypeScript Microservices to Kubernetes

Building and deploying a microservices application can be very challenging. This is usually because, with traditional frameworks, it requires a lot of work to set up and manage the necessary infrastructure. And once it is set up, you also need to make sure the services are working as expected, are secure and scalable, and can connect and communicate between each other.

Encore.ts is an Open Source TypeScript framework that solves these challenges and simplifies the entire process. It enables you to build robust distributed systems, where most of the heavy lifting is handled automatically.

In this tutorial, you’ll learn how to build a microservice with Encore.ts, and deploy it to a Kubernetes cluster in your AWS account. We’ll show you how Encore Cloud (Encore’s managed DevOps automation platform) automates the deployment process, handling everything from setting up the Kubernetes cluster and all necessary IAM policies and other resources, to deploying your microservices.

Prerequisites

  • Encore installed locally on your computer (we'll do this in the next step)
  • Docker (You need Docker to run Encore applications with databases locally.)
  • Code Editor of your choice

The code for this tutorial is available here, feel free to clone and follow along.

Install Encore

Install the Encore CLI to run your local environment:

  • macOS: brew install encoredev/tap/encore
  • Linux: curl -L https://encore.dev/install.sh | bash
  • Windows: iwr https://encore.dev/install.ps1 | iex

Creating a microservice with Encore

Let’s start by creating a microservice using Encore.ts. We’ll build a simple blog microservice to demonstrate how to deploy microservice applications using Encore.ts.

Create a New Application

Run the command to scaffold a new Encore.ts application:

encore app create blog-microservices
Enter fullscreen mode Exit fullscreen mode

The above command will prompt you to select the language for your application and project template. Your selection should look like the screenshot below:

Create app

Now change directory into the project folder and run your app with the command:

cd blog-microservices && encore run
Enter fullscreen mode Exit fullscreen mode

The above command will open up the API on your browser:

Local Dev Dashboard

Now let’s look at the folder structure for our microservice application. Create the following folder structure in your project directory.

blog-microservices/
├── encore.app
├── posts-service/
   ├── encore.service.ts    // Service definition
   ├── posts.ts            // API endpoints
   └── migrations/
       └── 1_create_posts.up.sql // Database migration definition
└── comments-service/
    ├── encore.service.ts    // Service definition
    ├── comments.ts         // API endpoints
    └── migrations/
        └── 1_create_comments.up.sql // Database migration definition
Enter fullscreen mode Exit fullscreen mode

In this project structure, we have two microservices, the posts-service and comments-service services. The posts-service handles all post related logics and functions such as creating new posts, fetching posts, updating and deleting posts. While the comments-service handles all the comments related operations. This way, your application is decompiled and allows you to easily manage each service independently.

Adding database integration

With the application and project files created, let’s proceed to creating a database and creating schema.

In your posts-service/posts.ts file and add the following code to setup a database posts database:

import { SQLDatabase } from "encore.dev/storage/sqldb";

// Database setup
const db = new SQLDatabase("comments", {
  migrations: "./migrations",
});
Enter fullscreen mode Exit fullscreen mode

Then update yourposts-service/migrations/1_create_posts.up.sql file, add the following code snippets to define a postsschema:

CREATE TABLE posts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    author_name TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

The above code will create a posts table with the following fields:

  • id: A random generated ID to uniquely identify each posts
  • title: The title of each post.
  • content: The actual blog post.
  • author_name: Name of the user posting the blog.
  • created_at: Date and time the post was created.

Next, add the following code to your comments-service/posts.ts file to create a comments database:

import { SQLDatabase } from "encore.dev/storage/sqldb";

// Database setup
const db = new SQLDatabase("posts", {
  migrations: "./migrations",
});
Enter fullscreen mode Exit fullscreen mode

Update the comments-service/migrations/1_create_comments.up.sql to create a comments schema:

CREATE TABLE comments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    post_id UUID NOT NULL,
    content TEXT NOT NULL,
    author_name TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
Enter fullscreen mode Exit fullscreen mode

The above code will create a comments table with the following fields:

  • id: A random generated ID to uniquely identify each comment.
  • post_id: ID of the post commented on, which creates a reference to the posts table.
  • content: The actual comment on the post.
  • author_name: Name of the user commenting on the posts.
  • created_at: Date and time the post was created.

Writing a basic REST API service

Now update your posts-service/posts file to create REST API services for the posts microservice. We’ll add do endpoints, to create, fetch all posts and get posts by ID from the database:

//...
import { api, Query } from "encore.dev/api";
import { Topic } from "encore.dev/pubsub";

//...

// Types
interface PostEvent {
  id: string;
  title: string;
  authorName: string;
  action: "created" | "updated" | "deleted";
}

export const postCreatedTopic = new Topic<PostEvent>("post-created", {
  deliveryGuarantee: "at-least-once",
});

interface Post {
  id: string;
  title: string;
  content: string;
  authorName: string;
  createdAt: Date;
}

interface CreatePostRequest {
  title: string;
  content: string;
  authorName: string;
}

interface ListPostsRequest {
  limit?: Query<number>;
  offset?: Query<number>;
}

interface ListPostsResponse {
  posts: Post[];
  total: number;
}

// API Endpoints
export const createPost = api(
  {
    method: "POST",
    path: "/posts",
    expose: true,
  },
  async (req: CreatePostRequest): Promise<Post> => {
    const post = await db.queryRow<Post>`
            INSERT INTO posts (title, content, author_name)
            VALUES (${req.title}, ${req.content}, ${req.authorName})
            RETURNING 
                id,
                title,
                content,
                author_name as "authorName",
                created_at as "createdAt"
        `;

    await postCreatedTopic.publish({
      id: post?.id as string,
      title: post?.title as string,
      authorName: post?.authorName as string,
      action: "created",
    });
    return post as Post;
  }
);

export const getPost = api(
  {
    method: "GET",
    path: "/posts/:id",
    expose: true,
  },
  async (params: { id: string }): Promise<Post> => {
    return (await db.queryRow<Post>`
            SELECT 
                id,
                title,
                content,
                author_name as "authorName",
                created_at as "createdAt"
            FROM posts 
            WHERE id = ${params.id}
        `) as Post;
  }
);

export const listPosts = api(
  {
    method: "GET",
    path: "/posts",
    expose: true,
  },
  async (params: ListPostsRequest): Promise<ListPostsResponse> => {
    const limit = params.limit || 10;
    const offset = params.offset || 0;

    // Get total count
    const totalResult = await db.queryRow<{ count: string }>`
            SELECT COUNT(*) as count FROM posts
        `;
    const total = parseInt(totalResult?.count || "0");

    // Get paginated posts
    const posts = await db.query<Post>`
            SELECT 
                id,
                title,
                content,
                author_name as "authorName",
                created_at as "createdAt"
            FROM posts
            ORDER BY created_at DESC
            LIMIT ${limit} OFFSET ${offset}
        `;

    const result: Post[] = [];
    for await (const post of posts) {
      result.push(post);
    }

    return {
      posts: result,
      total,
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

In the above code snippet, we defined a series of TypeScript interfaces and API routes that manages posts service. The PostEvent interface is used to shape messages sent to a message topic (postCreatedTopic), which records actions like post creation, updates, and deletions. The main Post interface defines the structure of a blog post, while CreatePostRequest, ListPostsRequest, and ListPostsResponse provide types for request and response handling in the service's API endpoints. We created three routes, createPost which adds a new post to the database and publishes an event to the topic; getPost, which retrieves a specific post by its ID; and listPosts, which provides paginated post data.

Next, update the comments-service/posts to create REST API services for the comments microservice:

//...
import { api, Query } from "encore.dev/api";

//...

// Types
interface Comment {
  id: string;
  postId: string;
  content: string;
  authorName: string;
  createdAt: Date;
}

interface CreateCommentRequest {
  postId: string;
  content: string;
  authorName: string;
}

interface ListCommentsRequest {
  limit?: Query<number>;
  offset?: Query<number>;
  postId: string;
}

interface ListCommentsResponse {
  comments: Comment[];
}

export const listComments = api(
  {
    method: "GET",
    path: "/comments/:postId",
    expose: true,
  },
  async (params: ListCommentsRequest): Promise<ListCommentsResponse> => {
    const limit = params.limit || 10;
    const offset = params.offset || 0;

    const comments = await db.query<Comment>`
            SELECT
                id,
                post_id as "postId",
                content,
                author_name as "authorName",
                created_at as "createdAt"
            FROM comments
            WHERE post_id = ${params.postId}
            ORDER BY created_at DESC
            LIMIT ${limit} OFFSET ${offset}
        `;

    const result: Comment[] = [];
    for await (const comment of comments) {
      result.push(comment);
    }
    return { comments: result };
  }
);
Enter fullscreen mode Exit fullscreen mode

In this code, we defined interfaces and API routes to manage comments on blog posts. The Comment interface shapes the data structure for each comment, including fields like postId and createdAt. The CreateCommentRequest, ListCommentsRequest, and ListCommentsResponse interfaces structure the requests and responses for our endpoints, ensuring type-safe interactions. We created a listComments, which retrieves a paginated list of comments for a specified post.

Implementing service-to-service communication

First we need to define each microservice as a service in Encore.

To do this for the posts service, add this code to the posts-service/encore.service.ts file:

import { Service } from "encore.dev/service";

export default new Service("posts");
Enter fullscreen mode Exit fullscreen mode

Then for the comments service, add this code to the comments-service/encore.service.ts file to create a comments service:

import { Service } from "encore.dev/service";

export default new Service("comments");
Enter fullscreen mode Exit fullscreen mode

Next, to call the endpoints in the posts service from the comments service, simply import it in the comments service, like so:

import { posts } from "~encore/clients";
Enter fullscreen mode Exit fullscreen mode

Now you can all its endpoints like normal functions from the comments service, like so:

// API Endpoints
export const createComment = api(
  {
    method: "POST",
    path: "/comments",
    expose: true,
  },
  async (req: CreateCommentRequest): Promise<Comment> => {
    // Verify post exists
    const post = await posts.getPost({ id: req.postId as string });
    if (!post) {
      throw new Error("Post not found");
    }

    return (await db.queryRow<Comment>`
            INSERT INTO comments (post_id, content, author_name)
            VALUES (${req.postId}, ${req.content}, ${req.authorName})
            RETURNING 
                id,
                post_id as "postId",
                content,
                author_name as "authorName",
                created_at as "createdAt"
        `) as Comment;
  }
);
Enter fullscreen mode Exit fullscreen mode

Here, we created a createComment, which allows users to add a comment to an existing post (after verifying the post exists) we imported the posts services and used it to access the getPost method which checks if the posts the user is trying to comment on exists.

Testing the service locally

Now let’s test the microservice API routes. Go back to your API explorer to test your endpoints:

Local testing

Deploying to Kubernates

Now that you have your microservices application up and running, let’s use Encore Cloud (Encore’s managed service for DevOps automation) to automatically deploy it to a Kubernetes cluster in your AWS account.

We’ll be deploying your microservice to a new Kubernetes cluster. You can find the guide on deploying to an existing Kubernetes cluster here.

Run the command below to deploying the application:

git add -A .
git commit -m 'first deploy'
git push encore
Enter fullscreen mode Exit fullscreen mode

Connecting your cloud account:

The first step in deploying your Encore.ts microservice to a Kubernetes cluster is connecting your cloud account, such as AWS or GCP, to your app in Encore Cloud. In this tutorial, we’ll use the Amazon Web Service(AWS) as an example. Follow the steps below to connect your AWS account to your app in Encore Cloud:

  1. From your Encore Cloud dashboard, navigate to:
    • Select your app
    • Go to App Settings > Integrations > Connect Cloud
  2. Login to your AWS account and create a new IAM Role with the following steps:
    1. Go to the Create Role page in the Identity and Access Management (IAM) console.
    2. Select Another AWS Account as the account type.
    3. Copy and paste your Account ID from Encore Cloud.
    4. Check the Require external ID option.
    5. Copy your External ID from **Encore Cloud and paste in the **External ID field in AWS. AWS config
    6. Attach the AdministratorAccess permission policy to the role (required by Encore to provision resources on your behalf). AWS Config
    7. Enter role name, description and click the Create Role button. AWS Config
    8. Paste your AWS Role ARN and click the Continue button to connect your Encore Cloud to AWS. Cloud is connected

Creating environment

Now that you have connected AWS console to your Encore.ts Cloud, let’s proceed to creating an new Enviroment to deploy the Microservice to Kubernates. To do that follow the steps below:

  1. Open your app in the Encore Cloud dashboard and go to Environments and click Create Environment. Create environment
  2. Select your cloud and compute platform.
    • Choose AWS as your cloud provider. Env config
    • Specify Kubernetes as the compute platform (Encore supports GKE on GCP and EKS Fargate on AWS). Compute config
  3. Decide whether to allocate all services in a single process or run one process per service. Process config

Once you have those configurations in place, click the Create button to create your new environment. Encore will provision and deploy the infrastructure on Kubernetes based on your environment configuration.

Created environment

While your application is deploying you can monitor deployment status and environment details in your Encore Cloud dashboard. Also you can use the kubectl CLI tool to access your Kubernetes cluster.

Wrapping up

In this tutorial, you’ve learned how to build and deploy your Encore.ts microservice application to Kubernetes.

We started by understanding what Encore.ts is, and how it solves the challenges faced by developers when building and deploying microservices.

Then we built a microservice application and deployed it to a Kubernetes cluster on AWS using Encore Cloud.

Now that you know how it works, perhaps you can try adding more features to the app.

Related links:

Top comments (0)