This article guides you through setting up to build a basic cloud-native Web application using K8S, Docker, Yarn Workspace, TypeScript, ESBuild, Express, and React. By the end of this tutorial, you will have a Web application that can be fully built and deployed on K8S.

Setting up the project

The project will be constructed as Monorepo. The goal of Monorepo is to increase the amount of code shared between modules and to better predict how these modules will communicate together (for example, in microservices architectures). For the purposes of this exercise, we will keep the structure simple:

  • appIt will represent ourReact website.
  • server, it will useExpressServing usapp.
  • common, some of the code will be inappserverShared between.

The only requirement before setting up the project is to install YARN on the machine. Yarn is a package manager like NPM, but with better performance and slightly more functionality. You can read more about how to install it in the official documentation.

Workspaces

Go to the folder where you want to initialize the project, and then perform the following steps from your favorite terminal:

  1. usemkdir my-appCreate a folder for the project (you are free to choose the name you want).
  2. usecd my-appGo to the folder.
  3. useyarn initInitialize it. This will prompt you to create the initialpackage.jsonFile-related issues (don’t worry, once you create a file, you can modify it at any time). If you do not want to useyarn initCommand, you can always manually create a file and copy the following into it:
{
  "name": "my-app"."version": "1.0.0"."license": "UNLICENSED"."private": true // Required for yarn workspace to work
}
Copy the code

Now that we have created the package.json file, we need to create folders for our modules app, Common, and Server. To make it easier for YARN Workspace to find modules and improve readability of projects, we nested modules under packages:

My - app / ├ ─ packages /// Where all of our current and future modules will live│ ├─ App / │ ├─ Common / │ ├─ Server / ├─ package.jsonCopy the code

Each of our modules will act as a small, independent project and will need its own package.json to manage dependencies. To set each of them, we can either use YARN init (in each folder) or create files manually (for example, through an IDE).

The naming convention used for package names is to prefix each package with @my-app/*. This is called scope in the NPM world (you can read more about it here). You don’t have to prefix yourself like this, but it will help later.

Once you have created and initialized all three packages, you will have the similarities shown below.

The app package:

{
  "name": "@my-app/app"."version": "0.1.0 from"."license": "UNLICENSED"."private": true
}
Copy the code

Common package:

{
  "name": "@my-app/common"."version": "0.1.0 from"."license": "UNLICENSED"."private": true
}
Copy the code

Server package:

{
  "name": "@my-app/server"."version": "0.1.0 from"."license": "UNLICENSED"."private": true
}
Copy the code

Finally, we need to tell YARN where to look for the module, so go back and edit the project’s package.json file and add the following Workspaces properties (see YARN’s Workspaces documentation if you want more details).

{
  "name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"] // Add it here
}
Copy the code

Your final folder structure should look like this:

My - app / ├ ─ packages / │ ├ ─ app / │ │ ├ ─ package. The json │ ├ ─ common / │ │ ├ ─ package. The json │ ├ ─ server / │ │ ├ ─ package. The json ├ ─ package.jsonCopy the code

You have now completed the basic setup of the project.

TypeScript

Now let’s add our first dependency to our project: TypeScript. TypeScript is a superset of JavaScript that implements type checking at build time.

Go to the root directory of the project on the terminal and run yarn add -d -w typescript.

  • parameter-DTypeScriptAdded to thedevDependenciesBecause we only use it during development and build.
  • parameter-WAllows a package to be installed in the workspace root directory so that theapp,commonserverIs available globally.

Your package.json should look like this:

{
  "name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
    "typescript": "^ holdings"}}Copy the code

This also creates a yarn.lock file (which ensures that the expected version of the dependency remains the same throughout the life of the project) and a node_modules folder that holds the binaries of the dependency.

Now that we have TypeScript installed, it’s a good habit to tell it how to run. To do this, we’ll add a configuration file that should be picked up by your IDE (or automatically if you use VSCode).

Create a tsconfig.json file in the root directory of your project and copy the following into it:

{
  "compilerOptions": {
    /* Basic */
    "target": "es2017"."module": "CommonJS"."lib": ["ESNext"."DOM"]./* Modules Resolution */
    "moduleResolution": "node"."esModuleInterop": true./* Paths Resolution */
    "baseUrl": ". /"."paths": {
      "@flipcards/*": ["packages/*"]},/* Advanced */
    "jsx": "react"."experimentalDecorators": true."resolveJsonModule": true
  },
  "exclude": ["node_modules"."**/node_modules/*"."dist"]}Copy the code

You can easily search each compileOptions property and its actions, but the paths property is most useful to us. For example, this tells TypeScript where to look for code and Typings when importing @my-app/common in @my-app/server or @my-app/app packages.

Your current project structure should now look like this:

│ ├─ Common / │ │ ├─ Package. json │ │ │ ├─ Server │ │ ├─ ─ │ │ │ ├─ Package │ │ │ ├─ package │ │ │ │ ├─ package │ │ │ │ ├─ package │ │ │ │ ├─ Package. json ├─ Package. json ├─ TsConfig. json ├─ yarnCopy the code

Add the first script

Yarn Workspace allows us to access any subpackage through the Yarn Workspace @my-app/* command mode, but typing the full command each time becomes redundant. To do this, we can create helper Script methods to improve the development experience. Open package.json in the project root directory and add the following scripts properties to it.

{
  "name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
    "typescript": "^ holdings"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"}}Copy the code

You can now execute any command as in a subpackage. For example, you can add some new dependencies by typing YARN Server Add Express. This adds new dependencies directly to the Server package.

In the following sections, we’ll start building front-end and back-end applications.

Ready to Git

If you plan to use Git as a version control tool, it is strongly recommended that you ignore generated files, such as binaries or logs.

To do this, create a new file named.gitignore in the root directory of your project and copy the following into it. This will ignore some of the files that will be generated later in the tutorial and avoid committing a lot of unnecessary data.

# Logs
yarn-debug.log*
yarn-error.log*

# Binaries
node_modules/

# Builds
dist/
**/public/script.js
Copy the code

The folder structure should look like this:

My-app / ├─ Packages / ├─.gitignore ├─ package.jsonCopy the code

Add code

This section will focus on adding code to our Common, App, and Server packages.

Common

We’ll start with Common because this package will be used by app and Server. Its goal is to provide shared logic and variables.

file

In this tutorial, the Common package will be very simple. Start by adding a new folder:

  • src/Folder containing the code for the package.

After creating this folder, add the following files to it:

src/index.ts

export const APP_TITLE = 'my-app';
Copy the code

Now that we have some code to export, we want to tell TypeScript where to look for it when importing it from other packages. To do this, we will need to update the package.json file:

package.json

{
  "name": "@my-app/common"."version": "0.1.0 from"."license": "UNLICENSED"."private": true."main": "./src/index.ts" // Add this line to provide an entry point for TS
}
Copy the code

We have now completed the Common package!

Structure reminder:

Common / ├─ SRC / │ ├─ index.package. JsonCopy the code

App

dependency

The app package will require the following dependencies:

  • react
  • react-dom

Run from the root of the project:

  • yarn app add react react-dom
  • yarn app add -D @types/react @types/react-dom(forTypeScriptAdd a typetypings)

package.json

{
  "name": "@my-app/app"."version": "0.1.0 from"."license": "UNLICENSED"."private": true."dependencies": {
    "@my-app/common": "^ 0.1.0 from".// Notice that we've added this import manually
    "react": "^ 17.0.1"."react-dom": "^ 17.0.1"
  },
  "devDependencies": {
    "@types/react": "^ 17.0.3"."@types/react-dom": "^ 17.0.2"}}Copy the code

file

To create our React application, we will need to add two new folders:

  • apublic/Folder, which will save the baseHTMLPage and oursassets.
  • asrc/Folder that contains the code for our application.

Once these two folders are created, we can start adding the HTML file that will host our application.

public/index.html

<! DOCTYPEhtml>
<html>
  <head>
    <title>my-app</title>
    <meta name="description" content="Welcome on my application!" />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <! -- This div is where we will inject the React application -->
    <div id="root"></div>
    <! -- This is the path to the script containing our application -->
    <script src="script.js"></script>
  </body>
</html>
Copy the code

Now that we have pages to render, we can implement a very basic but fully functional React application by adding the following two files.

src/index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';

import { App } from './App';

ReactDOM.render(<App />.document.getElementById('root'));
Copy the code

This code hooks into the root div from our HTML file and injects the React component tree into it.

src/App.tsx

import { APP_TITLE } from '@flipcards/common';
import * as React from 'react';

export function App() :React.ReactElement {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <h1>Welcome on {APP_TITLE}!</h1>
      <p>
        This is the main page of our application where you can confirm that it
        is dynamic by clicking the button below.
      </p>

      <p>Current count: {count}</p>
      <button onClick={()= > setCount((prev) => prev + 1)}>Increment</button>
    </div>
  );
}
Copy the code

This simple App component will present our App title and dynamic counter. This will be our React Tree entry point. Feel free to add any code you want.

That’s it! We’ve completed the very basic React application. It doesn’t do much right now, but we can always use it later and add more features.

Structure reminder:

App / ├─ public/ │ ├─ index.html ├─ SRC / │ ├─ app.tsx │ ├─ package.jsonCopy the code

Server

dependency

The Server package will require the following dependencies:

  • cors
  • express

Run from the root of the project:

  • yarn server add cors express
  • yarn server add -D @types/cors @types/express(forTypeScriptAdd a typetypings)

package.json

{
  "name": "@my-app/server"."version": "0.1.0 from"."license": "UNLICENSED"."private": true."dependencies": {
    "@my-app/common": "^ 0.1.0 from".// Note that we have added this import manually
    "cors": "^ 2.8.5"."express": "^ 4.17.1"
  },
  "devDependencies": {
    "@types/cors": "^ 2.8.10"."@types/express": "^ 4.17.11"}}Copy the code

file

Now that our React application is ready, the final piece we need is the server to service it. First create the following folder for it:

  • asrc/Folder that contains the code for our server.

Next, add the server’s main file:

src/index.ts

import { APP_TITLE } from '@flipcards/common';
import cors from 'cors';
import express from 'express';
import { join } from 'path';

const PORT = 3000;

const app = express();
app.use(cors());

// Static resources from the "public" folder (e.g., when there are images to display)
app.use(express.static(join(__dirname, '.. /.. /app/public')));

// Serve HTML pages
app.get(The '*'.(req: any, res: any) = > {
  res.sendFile(join(__dirname, '.. /.. /app/public'.'index.html'));
});

app.listen(PORT, () = > {
  console.log(`${APP_TITLE}'s server listening at http://localhost:${PORT}`);
});
Copy the code

This is a very basic Express application, but if we don’t have any services other than a single-page application, that’s enough.

Structure reminder:

Server / ├─ SRC / │ ├─ ├─ Package.jsonCopy the code

Building an

Bundlers(Package build bundles)

To convert TypeScript code into interpretable JavaScript code and package all external libraries into a single file, we’ll use a packaging tool. There are many bundlers in the JS/TS ecosystem, such as WebPack, Parcel, or Rollup, but we will choose EsBuild. Compared to other bundlers, esBuild comes with many default loaded features (TypeScript, React) and a huge performance improvement (up to 100 times faster). If you’re interested in learning more, please take the time to read the author’s FAQ.

These scripts will require the following dependencies:

  • Esbuild is our bundle
  • ts-node 是 TypeScriptREPL, which we will use to execute the script

Run yarn add -d -w esbuild TS-node from the root directory of the project.

package.json

{
  "name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
    "esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"}}Copy the code

Build

Now we have all the tools we need to build the application, so let’s create our first script.

Start by creating a new folder called scripts/ under the root of your project.

Our script will be written in TypeScript and executed from the command line using TS-Node. Although there is a CLI for esBuild, it is more convenient to use the library through JS or TS if you want to pass more complex parameters or combine multiple workflows.

Create a build.ts file in the scripts/ folder and add the code below (I’ll comment on what the code does) :

scripts/build.ts

import { build } from 'esbuild';

/** * Generic options passed during build. * /
interface BuildOptions {
  env: 'production' | 'development';
}

/** * a constructor function for the app package. * /
export async function buildApp(options: BuildOptions) {
  const { env } = options;

  await build({
    entryPoints: ['packages/app/src/index.tsx'].// We read the React application from this entry point
    outfile: 'packages/app/public/script.js'.// We output a file in the public/ folder (remember that "script.js" is used in the HTML page)
    define: {
      'process.env.NODE_ENV': `"${env}"`.// We need to define the Node.js environment to build the application
    },
    bundle: true.minify: env === 'production'.sourcemap: env === 'development'}); }/** * Server package builder functionality. * /
export async function buildServer(options: BuildOptions) {
  const { env } = options;

  await build({
    entryPoints: ['packages/server/src/index.ts'].outfile: 'packages/server/dist/index.js'.define: {
      'process.env.NODE_ENV': `"${env}"`,},external: ['express'].// Some libraries must be marked as external
    platform: 'node'.// When building Node, we need to set the environment for it
    target: 'node14.15.5'.bundle: true.minify: env === 'production'.sourcemap: env === 'development'}); }/** * Builder functionality for all packages. * /
async function buildAll() {
  await Promise.all([
    buildApp({
      env: 'production',
    }),
    buildServer({
      env: 'production',})]); }This method is executed when we run the script from the terminal using TS-Node
buildAll();
Copy the code

The code is easy to interpret, but if you feel you’ve missed something, check out the ESBuild API documentation for a complete list of keywords.

Our build script is now complete! The last thing we need to do is add a new command to our package.json to make it easy to run the build operation.

{
  "name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
    "esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"."build": "ts-node ./scripts/build.ts" // Add this line here}}Copy the code

You can now start the build process by running YARN Build from the root folder of your project every time you make a change to the project (how to add hot-reloading is discussed later).

Structure reminder:

My-app / ├─ Packages / ├─ scripts/ │ ├─ Build.ts ├─ package.json ├─ tsConfig.jsonCopy the code

Serve = Serve

With our application built and ready to be used around the world, we just need to add one last command to package.json:

{
  "name": "my-app"."version": "1.0"."license": "UNLICENSED"."private": true."workspaces": ["packages/*"]."devDependencies": {
    "esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"."build": "ts-node ./scripts/build.ts"."serve": "node ./packages/server/dist/index.js" // Add this line here}}Copy the code

Since we are now dealing with pure JavaScript, we can start the server using the Node binary. Therefore, continue to run YARN Serve.

If you look at the console, you will see that the server is listening successfully. You can also open a browser and navigate to http://localhost:3000 to display your React app 🎉!

If you want to change the PORT at run time, you can prefix the serve command with an environment variable: PORT=4000 YARN Serve.

Docker 🐳

This section assumes that you are already familiar with container concepts.

In order to be able to create an image based on our code, we need to have Docker installed on our computer. To learn how to install on an OS, take a moment to review the official documentation.

Dockerfile

To generate a Docker image, the first step is to create a Dockerfile in the root directory of our project (these steps can be done entirely through the CLI, but using a configuration file is the default way to define the build steps).

FROM node:14.15.5-alpine

WORKDIR /usr/src/app

# Install dependencies as early as possible, so that if our application
Docker does not need to download dependencies again because some files have changed.
# from the next step (" COPY.." To begin with.
COPY ./package.json .
COPY ./yarn.lock .
COPY ./packages/app/package.json ./packages/app/
COPY ./packages/common/package.json ./packages/common/
COPY ./packages/server/package.json ./packages/server/
RUN yarn

# copy all files of our application (except those specified in.gitignore)
COPY.

# compiler app
RUN yarn build

# Port
EXPOSE 3000

# Serve
CMD [ "yarn"."serve" ]
Copy the code

I’ll try to be as detailed as possible about what’s happening here and why the order of these steps is important:

  1. FROMtellDockerUses the specified underlying image for the current context. In our case, we want to have one that worksNode.jsThe environment of the application.
  2. WORKDIRSets the current working directory in the container.
  3. COPYCopies files or folders from the current local directory (the root of the project) to the working directory in the container. As you can see, in this step, we only copy the files associated with the dependencies. This is becauseDockerEach result of each command in the build is cached as a layer. Because we want to optimize build time and bandwidth, we only want to reinstall dependencies when they change (which is usually less frequent than file changes).
  4. RUNshellTo execute the command.
  5. EXPOSEIs used for the container’s internal port (with our applicationPORT envHas nothing to do). Any value here should be good, but if you want to learn more, check it outThe official documentation.
  6. CMDIs intended to provide default values for the execution container.

If you want to learn more about these keywords, check out the Dockerfile reference.

Add the dockerignore

Using the.dockerignore file is not mandatory, but the following files are strongly recommended:

  • Make sure you are not copying garbage files into the container.
  • makeCOPYCommands are easier to use.

If you’re already familiar with it, it works just like the.gitignore file. You can copy the following into a.dockerignore file at the same level as Dockerfile, which will be extracted automatically.

README.md

# Git
.gitignore

# Logs
yarn-debug.log
yarn-error.log

# Binaries
node_modules
*/*/node_modules

# Builds
*/*/build
*/*/dist
*/*/script.js
Copy the code

Feel free to add any files you want to ignore to lighten your final image.

Build the Docker Image

Now that our application is ready for Docker, we need a way to generate the actual image from Docker. To do this, we’ll add a new command to the root package.json:

{
  "name": "my-app"."version": "1.0.0"."license": "MIT"."private": true."workspaces": ["packages/*"]."devDependencies": {
    "esbuild": "^ 0.9.6"."ts-node": "^ 9.1.1." "."typescript": "^ holdings"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app"."common": "yarn workspace @my-app/common"."server": "yarn workspace @my-app/server"."build": "ts-node ./scripts/build.ts"."serve": "node ./packages/server/dist/index.js"."docker": "docker build . -t my-app" // Add this line}}Copy the code

-t my-app tells docker to use the current directory (.) Find the Dockerfile and name the resulting image (-t) my-app.

Be sure to run the Docker daemon to use the Docker command on your terminal.

Now that the command is in the script for our project, you can run it using YARN Docker.

After running this command, you should expect to see the following terminal output:

Sending build context to Docker Daemon 76.16MB Step 1/12: FROM node:14.15.5-alpine --> c1babb15a629 Step 2/12: WORKDIR /usr/src/app ---> b593905aaca7 Step 3/12 : COPY ./package.json . ---> e0046408059c Step 4/12 : COPY ./yarn.lock . ---> a91db028a6f9 Step 5/12 : COPY ./packages/app/package.json ./packages/app/ ---> 6430ae95a2f8 Step 6/12 : COPY ./packages/common/package.json ./packages/common/ ---> 75edad061864 Step 7/12 : COPY ./packages/server/package.json ./packages/server/ ---> e8afa17a7645 Step 8/12 : RUN yarn ---> 2ca50e44a11a Step 9/12 : COPY . . ---> 0642049120cf Step 10/12 : RUN yarn build ---> Runningin15b224066078 YARN Run v1.22.5 $ts-node./scripts/build.ts Donein3.51 S. Removing intermediate Container 15B224066078 --> 9DCE2D505C62 Step 11/12: EXPOSE 3000 --> Runningin f363ce55486b
Removing intermediate container f363ce55486b
 ---> 961cd1512fcf
Step 12/12 : CMD [ "yarn"."serve" ]
 ---> Running in7debd7a72538 Removing intermediate container 7debd7a72538 ---> df3884d6b3d6 Successfully built df3884d6b3d6 Successfully  tagged my-app:latestCopy the code

That’s it! Our image is now created and registered on your machine for Docker to use. If you want to list the available Docker images, you can run the Docker image ls command:

→ docker image ls
REPOSITORY    TAG       IMAGE ID        CREATED          SIZE
my-app        latest    df3884d6b3d6    4 minutes ago    360MB
Copy the code

Run the command like this

Running a working Docker image from the command line is very simple: Docker run -d -p 318:3000 my-app

  • -dRun the container in split mode (in the background).
  • -pSets the port to expose the container in the format[host port]:[container port]). So if we want to add ports inside the container3000(rememberDockerfileIn theEXPOSEPort exposed to the outside of the container8000, we will putA 8000-3000Passed to the-pMark.

You can confirm that your container is running Docker PS. This will list all running containers:

If you have additional requirements and questions about starting containers, find more information here.

→ docker ps
CONTAINER ID    IMAGE     COMMAND                  CREATED          STATUS          PORTS                    NAMES
71465a89b58b    my-app    "Docker - entrypoint. S..."7 seconds ago Up 6 seconds 0.0.0.0:3000->3000/ TCP Determined_shockleyCopy the code

Now, open your browser and navigate to the following URL http://localhost:3000 to see the application you are running 🚀!