Background
Server racks in a data center with blue lighting

Photo: Taylor Vick on Unsplash

Deploying a TypeScript Express Backend on AWS EC2

Aadarsh Jha
November 15, 2023

Deploying a TypeScript Express Backend on AWS EC2

In today's cloud-centric world, deploying applications to production environments is a critical skill for any full-stack developer. In this guide, I'll walk you through the entire process of deploying a TypeScript Express.js backend to an AWS EC2 instance, from initial setup to final production configurations.

Table of Contents

Prerequisites

Before we begin, make sure you have the following:

  • A TypeScript Express.js application ready for deployment
  • An AWS account
  • Basic knowledge of Linux commands
  • SSH client installed on your local machine
  • Node.js and npm/yarn experience

Step 1: Preparing Your TypeScript Express Application

Let's start by ensuring our TypeScript Express application is production-ready. The structure of a typical TypeScript Express app should look something like this:

code
project/
├── src/
│   ├── controllers/
│   ├── models/
│   ├── routes/
│   ├── middlewares/
│   ├── utils/
│   └── index.ts
├── dist/
├── node_modules/
├── package.json
├── tsconfig.json
└── .env

Key Configuration Files

Let's review two critical configuration files:

package.json

Make sure your package.json includes the necessary scripts:

code
{
  "name": "typescript-express-api",
  "version": "1.0.0",
  "scripts": {
    "start": "node dist/index.js",
    "dev": "nodemon src/index.ts",
    "build": "tsc",
    "lint": "eslint . --ext .ts"
  },
  "dependencies": {
    "express": "^4.17.1",
    "dotenv": "^10.0.0",
    "cors": "^2.8.5"
  },
  "devDependencies": {
    "@types/express": "^4.17.13",
    "@types/node": "^16.7.1",
    "typescript": "^4.3.5",
    "ts-node": "^10.2.1",
    "nodemon": "^2.0.12"
  }
}

tsconfig.json

Ensure your TypeScript configuration is properly set up:

code
{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.test.ts"]
}

Test Your Build Locally

Before deploying, it's crucial to verify that your application builds and runs correctly:

code
# Install dependencies
npm install

# Build the application
npm run build

# Start the built application
npm start

If your application runs without errors, you're ready to move on to the deployment stage.

Step 2: Launching and Configuring an EC2 Instance

AWS Console Dashboard
Photo by Richard Horvath on Unsplash

Launch a New EC2 Instance

  1. Log in to your AWS Management Console
  2. Navigate to EC2 service
  3. Click "Launch Instance"

Instance Configuration

For a basic Express.js application, choose the following settings:

  • Amazon Machine Image (AMI): Amazon Linux 2 or Ubuntu 20.04 LTS
  • Instance Type: t2.micro (eligible for free tier)
  • Key Pair: Create a new key pair and download the .pem file
  • Network Settings: Allow SSH, HTTP, and HTTPS traffic
  • Configure Storage: Default 8GB SSD should be sufficient for a small app

Security Group Configuration

Configure a security group with the following rules:

  • Allow SSH (port 22) from your IP address
  • Allow HTTP (port 80) from anywhere
  • Allow HTTPS (port 443) from anywhere
  • Allow custom TCP on the port your application runs (e.g., 3000) from anywhere

Server security concept
Photo by FLY:D on Unsplash

Connect to Your Instance

Once the instance is running, connect to it via SSH:

code
# Change permissions for your key file
chmod 400 your-key-pair.pem

# Connect to your instance
ssh -i "your-key-pair.pem" ec2-user@your-ec2-public-dns.amazonaws.com

Step 3: Setting Up the EC2 Environment

After connecting to your instance, set up the environment with the necessary dependencies:

Installing Node.js and npm

For Amazon Linux 2:

code
# Update system packages
sudo yum update -y

# Install Node.js and npm
curl -fsSL https://rpm.nodesource.com/setup_16.x | sudo bash -
sudo yum install -y nodejs

# Verify installation
node -v
npm -v

For Ubuntu:

code
# Update system packages
sudo apt update

# Install Node.js and npm
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install -y nodejs

# Verify installation
node -v
npm -v

Installing Git

code
# For Amazon Linux
sudo yum install git -y

# For Ubuntu
sudo apt install git -y

# Verify installation
git --version

Installing PM2 for Process Management

PM2 is a process manager that will keep your application running and restart it if it crashes:

code
# Install PM2 globally
sudo npm install -g pm2

Step 4: Deploying Your Application

Cloning Your Repository

If your repository is public:

code
git clone https://github.com/yourusername/your-repo.git
cd your-repo

If your repository is private, you'll need to set up deploy keys:

  1. On your EC2 instance, generate an SSH key:

    code
    ssh-keygen -t rsa -b 4096 -C "your-email@example.com"
    cat ~/.ssh/id_rsa.pub
    
  2. Copy the printed key and add it to your GitHub repository's deploy keys in Settings > Deploy keys.

  3. Clone your repository using SSH:

    code
    git clone git@github.com:yourusername/your-repo.git
    cd your-repo
    

Installing Dependencies and Building

code
# Install dependencies
npm install

# Build the TypeScript application
npm run build

Environment Variables

Create a production .env file:

code
# Create .env file
touch .env

# Edit the file
nano .env

Add your environment variables:

code
PORT=3000
NODE_ENV=production
DATABASE_URL=your-database-url
# Other environment variables...

Running with PM2

Start your application with PM2:

code
# Start the application
pm2 start npm --name "typescript-express-app" -- start

# Other useful PM2 commands
pm2 status
pm2 logs
pm2 restart typescript-express-app
pm2 stop typescript-express-app

Set up PM2 to start on boot:

code
# Generate startup script
pm2 startup

# Save the current process list
pm2 save

Server monitoring concept
Photo by Luke Chesser on Unsplash

Step 5: Setting Up a Domain and HTTPS (Optional)

For a production environment, you'll likely want a domain name and HTTPS configuration:

Configuring Nginx as a Reverse Proxy

code
# Install Nginx
sudo yum install nginx -y  # Amazon Linux
# or
sudo apt install nginx -y  # Ubuntu

# Start Nginx
sudo systemctl start nginx
sudo systemctl enable nginx

Create a new Nginx configuration:

code
sudo nano /etc/nginx/conf.d/your-app.conf

Add the following configuration:

code
server {
    listen 80;
    server_name your-domain.com www.your-domain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Restart Nginx:

code
sudo systemctl restart nginx

Setting Up HTTPS with Let's Encrypt

Install Certbot:

code
# For Amazon Linux
sudo amazon-linux-extras install epel -y
sudo yum install certbot python-certbot-nginx -y

# For Ubuntu
sudo apt install certbot python3-certbot-nginx -y

Obtain a certificate:

code
sudo certbot --nginx -d your-domain.com -d www.your-domain.com

Step 6: Assigning an Elastic IP (Optional)

If you want your EC2 instance to have a static IP address:

  1. In the AWS console, go to EC2 > Elastic IPs
  2. Click "Allocate Elastic IP address"
  3. Select the newly allocated Elastic IP
  4. Click "Actions" > "Associate Elastic IP address"
  5. Select your instance and click "Associate"

Note: Elastic IPs are free only when associated with a running instance. You'll be charged for allocated EIPs that aren't associated with a running instance.

Cloud computing concept
Photo by Christina Morillo on Unsplash

Step 7: Continuous Deployment (Optional)

For automated deployments, you can set up a simple deployment script:

Create a deploy.sh file in your home directory:

code
#!/bin/bash

cd ~/your-repo
git pull
npm install
npm run build
pm2 restart typescript-express-app

Make it executable:

code
chmod +x ~/deploy.sh

You can now run ~/deploy.sh whenever you want to deploy updates.

For more sophisticated CI/CD, consider using GitHub Actions, Jenkins, or AWS CodeDeploy.

Step 8: Monitoring and Logging

Basic Monitoring with PM2

PM2 provides basic monitoring:

code
pm2 monit

Setting Up CloudWatch (Optional)

For more comprehensive monitoring, set up AWS CloudWatch:

code
# Install CloudWatch agent
sudo yum install amazon-cloudwatch-agent -y  # Amazon Linux
# or
sudo apt install amazon-cloudwatch-agent -y  # Ubuntu

# Configure CloudWatch agent
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-config-wizard

Common Issues and Troubleshooting

Application Won't Start

Check the logs:

code
pm2 logs

Verify that:

  • All dependencies are installed
  • The build was successful
  • Environment variables are correctly set
  • Port isn't already in use

Can't Connect to Application

Verify:

  • Security group allows traffic on your application port
  • Your application is listening on the correct interface (0.0.0.0, not localhost)
  • Firewall settings allow traffic

High CPU/Memory Usage

Monitor resource usage:

code
top
free -m

Consider:

  • Optimizing your application
  • Using a larger instance type
  • Implementing caching

Issues I Faced During Deployment

During my journey deploying TypeScript Express applications to EC2, I encountered several challenges that weren't covered in most tutorials. Here's what I learned the hard way:

1. TypeScript Build Errors on Low-Memory Instances

When trying to build a moderately complex TypeScript application on a t2.micro instance (1GB RAM), the build process would fail with memory errors:

code
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Solution: Increase the Node.js memory limit temporarily for the build process:

code
NODE_OPTIONS="--max-old-space-size=512" npm run build

2. API Routes Returning 404 Despite Working Locally

After deployment, many API routes that worked locally were returning 404 errors.

Root cause: The application was using case-sensitive file paths that worked on my macOS development machine but failed on the Linux EC2 instance.

Solution: Ensure consistent file naming conventions and update import paths to match exactly:

code
// Wrong (might work on macOS but fail on Linux)
import UserController from './controllers/User.controller';

// Correct (works consistently across platforms)
import UserController from './controllers/user.controller';

3. Security Group Configuration Issues

Despite setting up the security group correctly in the AWS console, I couldn't connect to my application from outside.

Root cause: I had configured the application to listen on localhost (127.0.0.1) instead of all interfaces.

Solution: Update the Express application to listen on all interfaces:

code
// Wrong
app.listen(process.env.PORT || 3000, () => {
  console.log(`Server running on port ${process.env.PORT || 3000}`);
});

// Correct
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
  console.log(`Server running on port ${process.env.PORT || 3000}`);
});

4. Unexpected Costs with Elastic IP

I was shocked to see unexpected charges on my AWS bill related to Elastic IP.

Root cause: I had allocated several Elastic IPs for testing but wasn't using all of them with running instances.

Solution: Always release unused Elastic IPs and be aware that they're only free when associated with a running EC2 instance.

5. Application Not Restarting After Server Reboot

After an AWS maintenance reboot, my application didn't automatically restart.

Root cause: PM2 startup script wasn't properly configured.

Solution: Make sure to run both commands after setting up PM2:

code
# Generate and run the startup script
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ec2-user --hp /home/ec2-user
pm2 save

6. Environment Variables Not Loading in Production

My application couldn't access environment variables despite having a proper .env file.

Root cause: The dotenv package wasn't being called in the production build.

Solution: Ensure dotenv is configured correctly in your application:

code
// Near the top of your entry file (e.g., index.ts)
import * as dotenv from 'dotenv';
dotenv.config();

7. Database Connection Timeouts

After deploying, the application would frequently lose database connections.

Root cause: Default connection pool settings weren't optimized for production.

Solution: Configure connection pooling appropriately:

code
const pool = new Pool({
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

8. SSL Certificate Renewal Failures

After a few months, HTTPS stopped working because the Let's Encrypt certificate wasn't renewed.

Solution: Set up automatic certificate renewal with a cron job:

code
echo "0 12 * * * root /usr/bin/certbot renew --quiet" | sudo tee -a /etc/crontab > /dev/null

Debugging issues concept
Photo by Markus Spiske on Unsplash

Conclusion

Deploying a TypeScript Express application to AWS EC2 is a multi-step process, but it gives you complete control over your environment and infrastructure. This approach is ideal for small to medium-sized applications where cost-efficiency and control are important factors.

Remember that this is just the beginning of your cloud journey. As your application grows, you might want to explore more advanced options like Docker containers, AWS ECS/EKS, or serverless architectures like AWS Lambda.

The skills you've learned in this tutorial form a solid foundation for deploying and managing web applications in a cloud environment, regardless of which specific technologies you choose to use in the future.

Successful deployment concept
Photo by Austin Distel on Unsplash

Happy coding and deploying!

Additional Resources