News

Because I don't do social media.

Migrate from Vercel to Serverless

Code examples and snippets.

A lot of people have been migrating to Vercel recently, but I found myself wanting to migrate 15 domains and about 20 projects from it, into somewhere I had a bit more control over.

Why I wanted to leave Vercel isn't really important to debate publicly, but I found their platform too opaque in terms of usage and I recently had a few bad experiences with their customer support.

So, naturally I wanted to use something that was Open Source, and which used either DigitalOcean or AWS. I found Serverless and after some research (I'd learned about it before) it seemed like the best candidate, deploying to AWS.

One important thing to note is that my projects were all in either "plain" Node.js, Node.js with TypeScript, plain "static" (HTML, JS, CSS, images/files), or Next.js 9+. This probably made it incredibly easier to migrate than, for example, using other technologies.

You can see a lot of these migrations in public repos, like my simple Next.js boilerplate, the one with a markdown blog, and FaunaDB + ElasticSearch. Coupled with the BudgetZen web app, you can see a few variations and the important things to note for migrating a Next.js app.

I can also show some other snippets from private repos. Here's a simple one where I have some conditional redirects.

An old vercel.json file that used to have:

{
  "version": 2,
  "scope": "brn",
  "alias": ["thoughts.brunobernardino.com"],
  "routes": [
    {
      "src": "/no-ambition-no-goals/?",
      "status": 301,
      "headers": {
        "Location": "https://brunobernardino.com/mindfulness"
      }
    },
    {
      "src": "/beefing-up-privacy/?",
      "status": 301,
      "headers": {
        "Location": "https://brunobernardino.com/privacy"
      }
    },
    {
      "src": "/using-limits-as-expanders/?",
      "status": 301,
      "headers": {
        "Location": "https://brunobernardino.com/simplify"
      }
    },
    {
      "src": "/.*",
      "status": 301,
      "headers": {
        "Location": "https://brunobernardino.com"
      }
    }
  ]
}

Now it requires 3 files: serverless.yml, handler.js, and package.json. They look like this:

serverless.yml

service: thoughts-redirect

provider:
  name: aws
  runtime: nodejs12.x
  region: us-east-1
  profile: BRN

plugins:
  - serverless-domain-manager

custom:
  customDomain:
    certificateName: "brunobernardino.com"
    domainName: thoughts.brunobernardino.com
    basePath: ''
    createRoute53Record: true

functions:
  index:
    handler: handler.redirect
    events:
      - http:
          path: /{any+}
          method: get
          integration: lambda-proxy
      - http:
          path: /
          method: get
          integration: lambda-proxy

handler.js

'use strict';

module.exports.redirect = (event, context, callback) => {
  const requestPath = event.path;

  let finalDomain = 'https://brunobernardino.com';

  if (requestPath.startsWith('/using-limits-as-expanders')) {
    finalDomain = 'https://brunobernardino.com/simplify';
  }

  if (requestPath.startsWith('/beefing-up-privacy')) {
    finalDomain = 'https://brunobernardino.com/privacy';
  }

  if (requestPath.startsWith('/no-ambition-no-goals')) {
    finalDomain = 'https://brunobernardino.com/mindfulness';
  }

  const response = {
    statusCode: 301,
    headers: {
      Location: finalDomain,
    },
    body: '',
  };

  callback(null, response);
};

package.json

{
  "name": "thoughts",
  "version": "1.0.0",
  "private": true,
  "description": "",
  "main": "index.js",
  "dependencies": {},
  "devDependencies": {
    "serverless-domain-manager": "5.1.0"
  },
  "scripts": {},
  "keywords": [],
  "author": "Bruno Bernardino <me@brunobernardino.com>",
  "license": "UNLICENSED"
}

That might seem like a lot more boilerplate, but it also means a lot more control over what's happening. To deploy I just run serverless deploy, after a first-time-only serverless create_domain (this will require the domain to be in Route53, but I'll go deeper in detail about that below).

A simple page loader that returns the same text/HTML (not an HTML page), can be converted from a simple index.html and a vercel.json like this:

index.html

👋

vercel.json

{
  "version": 2,
  "scope": "brn",
  "alias": ["domain1.com", "domain2.com", "domain3.com"]
}

To 3 files: serverless.yml, handler.js, and package.json (which looks the same as above). The first two now look like this:

serverless.yml

service: example-website

provider:
  name: aws
  runtime: nodejs12.x
  region: us-east-1
  profile: BRN

plugins:
  - serverless-domain-manager

custom:
  customDomains:
    - rest:
        certificateName: "domain1.com"
        domainName: domain1.com
        createRoute53Record: true
    - rest:
        certificateName: "domain2.com"
        domainName: domain2.com
        createRoute53Record: true
    - rest:
        certificateName: "domain3.com"
        domainName: domain3.com
        createRoute53Record: true

functions:
  index:
    handler: handler.serve
    events:
      - http:
          path: /
          method: get
          integration: lambda-proxy

handler.js

'use strict';

module.exports.serve = (event, context, callback) => {
  const response = {
    statusCode: 200,
    body: '👋',
  };

  callback(null, response);
};

And what about a simple static website?

Great question! I've got a few of those, and you can see a public migration which shows how simple that can be at my web3-type-converter. Again, having the domain hosted on Route53 is a requirement, but you were likely hosting the domain on Vercel before anyway.

And a basic backend-only app? I mean, it's serverless!

Right, that's the most common scenario, so I didn't think it would be complicated, and it wasn't! I've got a service that gets the top 5 HackerNews stories and adds them to my BlogInMail account. This runs via a GitHub Action (cron, daily), and is perfect for a serverless setup. I won't get into all the details, but the main change I had to make was converting the handler. It looked something like this:

old last bit of handler.js

export default async (req, res) => {
  const { token } = req.query;

  if (!token || typeof token !== 'string' || token !== process.env.SECRET_TOKEN) {
    res.status(401).json({ code: 1, message: 'Token is Required' });
    return;
  }

  await main();

  res.status(200).json({ code: 0, message: 'Stuff done' });
};

old vercel.json

{
  "version": 2,
  "scope": "brn",
  "alias": ["hackernews-job.example.com"],
  "github": {
    "enabled": false
  },
  "env": {
    "BLOGINMAIL_API_KEY": "@secret-bloginmail-api-key",
    "SECRET_TOKEN": "@secret-hackernews-token"
  },
  "builds": [
    {
      "src": "index.js",
      "use": "@vercel/node"
    }
  ]
}

And so I just had to adapt the request and response variables, accordingly. It became something like this:

new last bit of handler.js

module.exports.handle = async (event, context, callback) => {
  const { token } = event.queryStringParameters;

  const response = {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
    },
    body: '{}',
  };

  if (!token || typeof token !== 'string' || token !== process.env.SECRET_TOKEN) {
    response.statusCode = 401;
    response.body = JSON.stringify({ code: 1, message: 'Token is Required' });
    return response;
  }

  await main();

  response.body = JSON.stringify({ code: 0, message: 'Stuff done' });

  return response;
};

serverless.yml

service: hackernews-top-stories

provider:
  name: aws
  runtime: nodejs12.x
  region: us-east-1
  profile: BRN
  timeout: 30
  lambdaHashingVersion: 20201221
  apiGateway:
    shouldStartNameWithService: true

plugins:
  - serverless-dotenv-plugin
  - serverless-offline
  - serverless-domain-manager

package:
  excludeDevDependencies: true

custom:
  customDomain:
    certificateName: "example.com"
    domainName: hackernews-job.example.com
    basePath: ''
    createRoute53Record: true
  serverless-offline:
    noPrependStageInUrl: true

functions:
  index:
    handler: handler.handle
    events:
      - http:
          path: /
          method: get

To start it, I changed from vercel dev to serverless offline, and to deploy I use serverless deploy.

Done!

And that's it! I think I covered the different types of applications I had in Vercel that I migrated

Finishing notes: DNS and cost/price

A couple of important things to note is that migrating DNS from Vercel is actually a PITA. They allow importing Zone files, but do not export them (which I personally found shady), so I had to manually add them in Route53. It took some time.

I didn't do this because of cost, but my cost went down from $20 / month to less than $10 / month, mostly because of the 15 domains hosted in Route53 ($7.5 / month). I have a few hundred thousands of requests per month, so I qualify for the free tier in many things (not S3 nor CloudFront, though).

Thank you so much for being here. I really appreciate you!