Deploying a URL Shortener with Cloudflare Workers
Posted on
Cloudflare Workers provides a serverless execution environment that allows you to create entirely new applications or augment existing ones without configuring or maintaining infrastructure. They support NodeJS and WebAssembly (WASM), as well as any language that can compile to WASM.
Delivered from over 250 locations worldwide, Cloudflare could be the best way to bring down that latency that’s plaguing your customers. Claiming 0ms for cold starts, automatic scaling, 100k free requests per day, and edge storage built-in: Cloudflare offers a pretty compelling edge compute platform for serverless workloads.
Let’s see how we can deploy a low-latency serverless URL shortener to Cloudflare Workers with Pulumi.
Setting Up
You’ll need a Pulumi project setup with the Cloudflare package added to your Pulumi project and an API token configured.
pulumi new typescript
npm install @pulumi/cloudflare
pulumi new python
pip install pulumi-cloudflare
pulumi new go
go get github.com/pulumi/pulumi-cloudflare/sdk/v4/go/...
pulumi new yaml
Now that we’ve added the package, we need to configure it. You’ll need your Cloudflare accountId and an API Token. The permissions you need for the API token are:
- All Accounts
- Workers Tail:Read
- Workers Scripts:Edit
- Account Settings:Read
- All Zones
- Zone Settings:Edit
- Zone:Edit
- Workers Routes:Edit
- SSL and Certificates:Edit
- DNS:Edit
- All Users
- User Details:Read
You can reduce the scope of “All Zones”, but this means you cannot create a Zone with Pulumi and instead would need to use getZone
, described below.
pulumi config set --secret cloudflare:apiToken
pulumi config set cloudflare:accountId
Our URL Shortener
We’re going to use plain old JavaScript for our worker code, as this means we don’t need to transpile anything down. Our URL Shortener will store domains, short tokens, and long URLs in a JavaScript object. We’ll also provide a DEFAULT_URL
in-case we can’t match the path
requested.
We need to keep our worker source code separate from our Pulumi code, so we’ll be keeping this contained within a file called worker.js
.
// worker.js
const DEFAULT_URL = "https://youtube.com/pulumitv"
const redirects = {
"pulumi.tv": {
"modern-infrastructure": {
to: "https://www.youtube.com/playlist?list=PLyy8Vx2ZoWloyj3V5gXzPraiKStO2GGZw"
}
},
};
addEventListener("fetch", async (event) => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const requestUrl = new URL(request.url);
const domain = requestUrl.host;
const path = requestUrl.pathname.substring(1);
console.log(`Handling request on domain ${domain} for ${path}`);
if (!(domain in redirects)) {
console.log(`Domain '${domain}' not found`);
return Response.redirect(DEFAULT_URL);
}
if (path === "") {
return Response.redirect(DEFAULT_URL);
}
const paths = redirects[domain];
if (path in paths) {
console.log(`Redirecting too ${paths[path].to}`);
return Response.redirect(paths[path].to);
}
console.log(`Path '${path}' not found`);
return Response.redirect(DEFAULT_URL);
}
Deploying Our Worker
Now on to our Pulumi code. We need to read our JavaScript code and send it to Cloudflare Workers via the WorkerScript
resource.
// index.ts
import * as cloudflare from "@pulumi/cloudflare";
import * as fs from "fs";
const worker = new cloudflare.WorkerScript(
"url-shortener",
{
name: "url-shortener",
content: fs.readFileSync("worker.js").toString(),
},
);
# __main__.py
import pulumi
import pulumi_cloudflare as cloudflare
worker_script_file = open("worker.js", "r")
worker_script = worker_script_file.read()
worker_script_file.close()
worker = cloudflare.WorkerScript("url-shortener",
name="url-shortener",
content=worker_script)
// main.go
package main
import (
"fmt"
"io/ioutil"
"github.com/pulumi/pulumi-cloudflare/sdk/v4/go/cloudflare"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
workerScriptFile, err := ioutil.ReadFile("worker.js")
if err != nil {
log.Fatal(err)
}
workerScript := string(workerScriptFile)
worker, err := cloudflare.NewWorkerScript(ctx, "url-shortener", &cloudflare.WorkerScriptArgs{
Content: pulumi.String(workerScript),
Name: pulumi.String("links"),
}, pulumi.Protect(true))
if err != nil {
return err
}
return nil
})
}
# Pulumi.yaml
name: url-shortener
runtime: yaml
description: url-shortener in Pulumi YAML
variables:
workerScript:
Fn::ReadFile: ./worker.js
resources:
worker:
type: cloudflare:index:WorkerScript
properties:
name: url-shortener
content: ${workerScript}
Where’s the Types?!
If you wanted to use TypeScript for the worker code for some guarantees around the short/long URL objects, you can definitely do so. This does require an additional step to transpile the TypeScript to JavaScript though. Remember to update the Pulumi code to read the ts
extension.
// worker.ts
interface Redirect {
to: string;
}
interface Redirects {
[domain: string]: {
[path: string]: Redirect;
};
}
const redirects: Redirects = {
"pulumi.tv": {
"/": {
to: "https://youtube.com/pulumitv",
},
"modern-infrastructure": {
to: "https://www.youtube.com/playlist?list=PLyy8Vx2ZoWloyj3V5gXzPraiKStO2GGZw"
}
},
};
You’ll also need a tsconfig.json
:
{
"compilerOptions": {
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2020"],
"module": "ES2020",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"outDir": "dist",
"pretty": true,
"sourceMap": true,
"strict": true,
"target": "ES2020",
"outDir": "dist",
"types": ["@cloudflare/workers-types"]
},
"include": ["index.ts"]
}
Then we can transpile the code with a local.Command
resource.
We need to add our command package and then add a local command to our code.
npm install @pulumi/command
// index.ts
import * as command from "@pulumi/command";
const compileWorker = new command.local.Command("compile-worker", {
create:
"yarn run tsc >/dev/null && cat ./dist/index.js",
dir: "../worker",
});
You could then use the stdout
output, compileWorker.stdout
, from our local command resource for the content
input on the WorkerScript
resource, instead of fs.readFileSync
.
pip install pulumi_command
# __main__.py
import pulumi_command as command
compile_worker = command.local.Command('compile-worker', create='yarn run tsc >/dev/null && cat ./dist/index.js')
You could then use the stdout
output, compile_worker.stdout
, from our local command resource for the content
input on the WorkerScript
resource, instead of open
and read
on the file.
go get github.com/pulumi/pulumi-command/sdk/go/...
// main.go
import (
"github.com/pulumi/pulumi-command/sdk/go/command/local"
)
compileWorker, err := local.NewCommand(ctx, "compile-worker", &local.CommandArgs{
Create: pulumi.String("yarn run tsc >/dev/null && cat ./dist/index.js"),
})
if err != nil {
return err
}
You could then use the stdout
output, compileWorker.Stdout
, from our local command resource for the content
input on the WorkerScript
resource, instead of ioutil.ReadFile
.
# Pulumi.yaml
resources:
compileWorker:
type: command:local:Command
properties:
create: yarn run tsc >/dev/null && cat ./dist/index.js
You could then use the stdout
output, ${compileWorker.stdout}
, from our local command resource for the content
input on the WorkerScript
resource, instead of Fn:ReadFile
.
Hooking Up a Domain
Cloudflare Workers allow us to connect a domain name, instead of using the generated workers.dev
subdomain. To do so with Pulumi, we need to create or fetch a Cloudflare Zone.
Create a Zone(s)
Once we create the cloudflare.Zone
resource, we also need to create a cloudflare.ZoneSettingsOverride
resource to ensure that we enable:
alwaysUseHttps: "on"
automaticHttpsRewrites: "on"
ssl: "strict"
universalSsl: "on"
.
Without these settings, our traffic will be available over HTTP and … come on, it’s 2022 already.
// index.ts
const zone = new cloudflare.Zone("pulumi.tv", {
zone: "pulumi.tv",
plan: "free",
});
const zoneSettings = new cloudflare.ZoneSettingsOverride("pulumi.tv", {
zoneId: z.id,
settings: {
alwaysUseHttps: "on",
automaticHttpsRewrites: "on",
ssl: "strict",
minTlsVersion: "1.2",
universalSsl: "on",
},
});
# __main__.py
zone = cloudflare.Zone("pulumi.tv", zone="pulumi.tv", plan="free")
zone_settings = cloudflare.ZoneSettingsOverride(
"pulumi.tv",
zone_id=zone.id,
settings=cloudflare.ZoneSettingsOverrideSettingsArgs(
always_use_https="on",
automatic_https_rewrites="on",
ssl="strict",
min_tls_version="1.2",
universal_ssl="on",
),
)
// main.go
zone, err := cloudflare.NewZone(ctx, "pulumi.tv", &cloudflare.ZoneArgs{
Zone: pulumi.String("pulumi.tv"),
Plan: pulumi.String("free"),
})
if err != nil {
return err
}
_, err = cloudflare.NewZoneSettingsOverride(ctx, "pulumi.tv", &cloudflare.ZoneSettingsOverrideArgs{
ZoneId: zone.ID(),
Settings: &cloudflare.ZoneSettingsOverrideSettingsArgs{
AlwaysUseHttps: pulumi.String("on"),
AutomaticHttpsRewrites: pulumi.String("on"),
UniversalSsl: pulumi.String("on"),
Ssl: pulumi.String("strict"),
MinTlsVersion: pulumi.String("1.2"),
},
})
if err != nil {
return err
}
# Pulumi.yaml
resources:
zone:
type: cloudflare:index:Zone
properties:
zone: pulumi.tv
plan: free
zoneSettings:
type: cloudflare:index:ZoneSettingsOverride
properties:
zoneId: ${zone.id}
settings:
alwaysUseHttps: "on"
automaticHttpsRewrites: "on"
universalSsl: "on"
ssl: "strict"
minTlsVersion: "1.2"
Fetch a Zone
// index.ts
const zone = await cloudflare.getZone({
name: "pulumi.tv",
});
# __main__.py
zone = cloudflare.get_zone(name="pulumi.tv")
// main.go
zone, err := cloudflare.LookupZone(ctx, &cloudflare.LookupZoneArgs{
Name: pulumi.String("pulumi.tv"),
})
# Pulumi.yaml
variables:
zoneId:
Fn::Invoke:
Function: cloudflare:index:getZone
Arguments:
name: pulumi.tv
Return: id
zoneName:
Fn::Invoke:
Function: cloudflare:index:getZone
Arguments:
name: pulumi.tv
Return: name
Creating the Routes
In-order to actually send traffic to our workers from these zones, we need to setup the DNS records and create the routes. While Cloudflare does provide a web UI to “connect” a custom domain, in beta, this isn’t yet available over the Cloudflare API.
Instead, we need to use a proxied DNS record on a subdomain, or domain apex, that points to workers.dev
.
// index.ts
const record = new cloudflare.Record(
"pulumi.tv",
{
zoneId: zone.id,
name: "@",
type: "CNAME",
value: "workers.dev",
proxied: true,
},
{
deleteBeforeReplace: true,
}
);
const route = new cloudflare.WorkerRoute(
"pulumi.tv",
{
zoneId: zone.id,
pattern: pulumi.interpolate`${zone.zone}/*`,
scriptName: worker.name,
}
);
# __main__.py
from pulumi import Output
record = cloudflare.Record("pulumi.tv",
name="@", zone_id=zone.id, type="CNAME", value="workers.dev")
route = cloudflare.WorkerRoute("pulumi.tv", zone_id=zone.id, pattern=Output.concat(zone.zone, "/*"), script_name=worker.name)
// main.go
_, err = cloudflare.NewRecord(ctx, "pulumi.tv", &cloudflare.RecordArgs{
Name: pulumi.String("@"),
ZoneId: zone.ID(),
Type: pulumi.String("CNAME"),
Value: pulumi.String("workers.dev"),
})
if err != nil {
return err
}
_, err = cloudflare.NewWorkerRoute(ctx, "pulumi.tv", &cloudflare.WorkerRouteArgs{
ZoneId: zone.ID(),
Pattern: pulumi.Sprintf("%s/*", zone.Zone),
ScriptName: worker.Name,
})
if err != nil {
return err
}
# Pulumi.yaml
resources:
record:
type: cloudflare:index:Record
properties:
name: "@"
zoneId: ${zone.id}
type: CNAME
value: workers.dev
route:
type: cloudflare:index:WorkerRoute
properties:
# If you fetched a Zone, this would be ${zoneId}
zoneId: ${zone.id}
# If you fetched a Zone, this would be ${zoneName}
pattern: ${zone.zone}/*
scriptName: ${worker.name}
Fin
Lastly, we can run pulumi up
and run a curl
π
β― pulumi up
Type Name Status
+ pulumi:pulumi:Stack short-links-production created
+ ββ cloudflare:index:Zone pulumi.tv created
+ ββ cloudflare:index:WorkerScript links created
+ ββ cloudflare:index:ZoneSettingsOverride pulumi.tv created
+ ββ cloudflare:index:Record pulumi.tv created
+ ββ cloudflare:index:WorkerRoute pulumi.tv created
Resources:
+ 7 created
Duration: 27s
β― curl -I https://pulumi.tv
HTTP/2 302
date: Wed, 29 Jun 2022 21:57:20 GMT
location: https://youtube.com/pulumitv
β― curl -I https://pulumi.tv/modern-infrastructure
HTTP/2 302
date: Wed, 29 Jun 2022 21:57:20 GMT
location: https://www.youtube.com/playlist?list=PLyy8Vx2ZoWloyj3V5gXzPraiKStO2GGZw
That’s it! Deploying your own URL Shortener with some JavaScript and Cloudflare Workers with Pulumi.
Complete Code
// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as cloudflare from "@pulumi/cloudflare";
import * as fs from "fs";
const zone = new cloudflare.Zone("pulumi.tv", {
"pulumi.tv",
plan: "free",
});
const zoneSettings = new cloudflare.ZoneSettingsOverride("pulumi.tv", {
zoneId: zone.id,
settings: {
alwaysUseHttps: "on",
automaticHttpsRewrites: "on",
ssl: "strict",
minTlsVersion: "1.2",
universalSsl: "on",
},
});
const worker = new cloudflare.WorkerScript(
"pulumi.tv",
{
name: "pulumi.tv",
content: fs.readFileSync("worker.js").toString(),
}
);
const record = new cloudflare.Record(
"pulumi.tv",
{
zoneId: zone.id,
name: "@",
type: "CNAME",
value: "workers.dev",
proxied: true,
},
{
deleteBeforeReplace: true,
}
);
const workerRoute = new cloudflare.WorkerRoute(
"pulumi.tv",
{
zoneId: zone.id,
pattern: pulumi.interpolate`${zone.zone}/*`,
scriptName: worker.name,
}
);
# __main__.py
import pulumi
from pulumi import Output
import pulumi_cloudflare as cloudflare
worker_script_file = open("worker.js", "r")
worker_script = worker_script_file.read()
worker_script_file.close()
worker = cloudflare.WorkerScript("url-shortener", name="url-shortener", content=worker_script)
zone = cloudflare.Zone("pulumi.tv", zone="pulumi.tv", plan="free")
zone_settings = cloudflare.ZoneSettingsOverride(
"pulumi.tv",
zone_id=zone.id,
settings=cloudflare.ZoneSettingsOverrideSettingsArgs(
always_use_https="on",
automatic_https_rewrites="on",
ssl="strict",
min_tls_version="1.2",
universal_ssl="on",
),
)
record = cloudflare.Record("pulumi.tv",
name="@", zone_id=zone.id, type="CNAME", value="workers.dev")
route = cloudflare.WorkerRoute("pulumi.tv", zone_id=zone.id, pattern=Output.concat(zone.zone, "/*"), script_name=worker.name)
// main.go
package main
import (
"io/ioutil"
"log"
"github.com/pulumi/pulumi-cloudflare/sdk/v4/go/cloudflare"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
workerScriptFile, err := ioutil.ReadFile("worker.js")
if err != nil {
log.Fatal(err)
}
workerScript := string(workerScriptFile)
worker, err := cloudflare.NewWorkerScript(ctx, "url-shortener", &cloudflare.WorkerScriptArgs{
Content: pulumi.String(workerScript),
Name: pulumi.String("links"),
}, pulumi.Protect(true))
if err != nil {
return err
}
zone, err := cloudflare.NewZone(ctx, "pulumi.tv", &cloudflare.ZoneArgs{
Zone: pulumi.String("pulumi.tv"),
Plan: pulumi.String("free"),
})
if err != nil {
return err
}
_, err = cloudflare.NewZoneSettingsOverride(ctx, "pulumi.tv", &cloudflare.ZoneSettingsOverrideArgs{
ZoneId: zone.ID(),
Settings: &cloudflare.ZoneSettingsOverrideSettingsArgs{
AlwaysUseHttps: pulumi.String("on"),
AutomaticHttpsRewrites: pulumi.String("on"),
UniversalSsl: pulumi.String("on"),
Ssl: pulumi.String("strict"),
MinTlsVersion: pulumi.String("1.2"),
},
})
if err != nil {
return err
}
_, err = cloudflare.NewRecord(ctx, "pulumi.tv", &cloudflare.RecordArgs{
Name: pulumi.String("@"),
ZoneId: zone.ID(),
Type: pulumi.String("CNAME"),
Value: pulumi.String("workers.dev"),
})
if err != nil {
return err
}
_, err = cloudflare.NewWorkerRoute(ctx, "pulumi.tv", &cloudflare.WorkerRouteArgs{
ZoneId: zone.ID(),
Pattern: pulumi.Sprintf("%s/*", zone.Zone),
ScriptName: worker.Name,
})
if err != nil {
return err
}
return nil
})
}
# Pulumi.yaml
name: url-shortener
runtime: yaml
description: url-shortener in Pulumi YAML
variables:
workerScript:
Fn::ReadFile: ./worker.js
resources:
worker:
type: cloudflare:index:WorkerScript
properties:
name: url-shortener
content: ${workerScript}
zone:
type: cloudflare:index:Zone
properties:
zone: rawkoded.link
plan: free
zoneSettings:
type: cloudflare:index:ZoneSettingsOverride
properties:
zoneId: ${zone.id}
settings:
alwaysUseHttps: "on"
automaticHttpsRewrites: "on"
universalSsl: "on"
ssl: "strict"
minTlsVersion: "1.2"
record:
type: cloudflare:index:Record
properties:
name: "@"
zoneId: ${zone.id}
type: CNAME
value: workers.dev
route:
type: cloudflare:index:WorkerRoute
properties:
zoneId: ${zone.id}
pattern: ${zone.zone}/*
scriptName: ${worker.name}