To connect a Cloudflare Worker securely to a self-hosted origin database server.
Exposing your database on the public Internet is a security risk. Malicious actors search the Internet for open database ports and attempt brute-force user/password attacks to gain access to your sensitive or proprietary data.
This risk can be mitigated by blocking database access to the Internet and instead, employing a secure tunnel between Cloudflare Worker and your database.
This repo is a working improvement on the worker-mysql repo from Cloudflare Workers Team, which I was unable to secure with Cloudflare Tunnel and Cloudflare Access.
This repo runs Cloudflare Tunnel (cloudflared
service) and MySQL database locally, in a Docker container. These will work on a remote server too. This example can be modified to support Postgres databases by making the same changes to the Postgres database driver.
Cloudflare Tunnels are now free.
CF_CLIENT_ID
, CF_CLIENT_SECRET
)Docker for running cloudflared
and MySQL locally.
If using Mac or PC, Docker Desktop is a good choice.
Node Version Manager to run the correct version of Node.
Run nvm install
to install the required Node version on your local development environment.
Then run nvm use
in each terminal window to use the required Node version.
You will need a Cloudflare Site/Website setup in the Cloudflare dashboard. Follow these setup instructions.
This example uses the example.com domain. Please substitute with your own domain.
Via the Cloudflare UI, create a tunnel between Cloudflare Worker and dockerised MySQL database.
MySQL will be running within a Docker container, therefore from the perspective of cloudflared
the MySQL hostname/IP is host.docker.internal
, the Docker VMs IP. The MySQL port is 3306
.
Note, if you're NOT running MySQL within Docker, use Service: tcp://127.0.0.1:3306
.
Ignore warnings regarding DNS entry not existing. This will automatically create a DNS CNAME on the domain, pointing to your Tunnel.
The status of your newly created tunnel will not show as "Active" yet. It will become active once you've setup and started cloudflared
in the next step.
References:
From the CLI, login to Cloudflare to authorise the Cloudflare Tunnel. You will be asked to open a link in your browser. Run npm run cloudflared:login
Running this command will generate and copy a certifcate to /home/nonroot/.cloudflared/cert.pem
. In this example, you do not need to move the cert but may need to copy it to a remote server at a later time should you want to use this tunnel on a remote server.
The cloudflared
service will need to run within your own server's private network. In most situations, cloudflared
will run on the same server as your database server, but it can run on a separate server within your private network. In this example, cloudflared
will run within a Docker container alongside MySQL on your local development computer (127.0.0.1, host.docker.internal).
Important note: if you're running cloudflared
within Docker and NOT running MySQL within Docker, use Service: tcp://127.0.0.1:3306
. By default, Docker containers cannot make outbound network calls to host (127.0.0.1) ports - therefore Cloudflare cannot reach MySQL running on the host (127.0.0.1). Setup Cloudflared to run on the same network as the host by adding network_mode: host
to the docker-compose.yml file (see commented out line).
Modify docker-compose.yml environment variables to include the Cloudflare Tunnel's token from the Cloudflare Tunnel dashboard:
TUNNEL_TOKEN=""
You can find the token on Cloudflare Zero Trust dashboard under "Access" > "Tunnels":
Once TUNNEL_TOKEN
has been populated, the cloudflared
Docker container can be started with npm run cloudflared
. This will also start the MySQL container as they're linked in the docker-compose.yml
.
Reference:
A Service Token is required to secure the Tunnel from the public. The Service Token is stored in the Cloudflare Worker and Cloudflare Application, which we'll setup in the next step.
Create Service Tokens on Cloudflare Zero Trust dashboard under "Access" > "Service Auth":
Click "Create Service Token" and provide a meaningful name (eg "db-tunnel-dev-service-token") and expiry date.
You will be presented with CF_CLIENT_ID
and CF_CLIENT_SECRET
. Copy the secret and treat it as a sensitive password.
These will be needed by your Cloudflare Worker to authenticate on the Cloudflare Tunnel.
Secure the tunnel from malicious actors by restricting who can hit your Cloudflare Tunnel's hostname.
Create Application on Cloudflare Zero Trust dashboard under "Access" > "Applications":
Step 1:
Step 2:
All communication through the Cloudflare Tunnel needs to include the Service Token in request headers when communicating through the tunnel:
CF-Access-Client-Id: e9dbd31ad.....98bf458b4.access
CF-Access-Client-Secret: 09725548845e2edaea70.......27e03a3785e39c59a50
This exercise ensures that the MySQL client sends the above headers when it communicates with the origin server (your local dockerised MySQL) via the Cloudflare Tunnel. The MySQL client has appended the above headers to each request.
CF-Access-Client-Id
and CF-Access-Client-Secret
contain the values from the environment variables CF_CLIENT_ID
and CF_CLIENT_SECRET
respectively. These environment variables were created when the Cloudflare Service Token was generated above.
Whilst CF_CLIENT_ID
and CF_CLIENT_SECRET
can be added to ./wrangler.toml in plain text, it's more secure to persist these values in Wrangler Secrets on the command line:
npx wrangler secret put CF_CLIENT_ID
npx wrangler secret put CF_CLIENT_SECRET
When persisted in Secrets environment variables, these will be automatically available to your Workers when published.
npm install
db-tunnel-dev.example.com
. The protocol is "https", not "tcp":
[vars]
TUNNEL_HOST = "https://db-tunnel-dev.example.com"
TUNNEL_TOKEN
from an earlier step.npm run dev
If everything has been setup correctly, visiting https://db-tunnel-dev.example.com in the browser should return a Cloudflare Access 401 Forbidden
page. This is because you haven't made th request with a valid Cloudflare Service Token (CF-Access-Client-Id
and CF-Access-Client-Secret
headers).
Request public DB endpoint (https://db-tunnel-dev.example.com) from browser, without Service Token, will result in the request being blocked (hooray!):
The steps above have established a secure tunnel which provides a secure doorway into your private network. Make sure your MySQL database is in your private network and not public network, accessible directly on the Internet.
Implement a firewall on your local develoment computer (or server) to ensure MySQL's port 3306 is not accessible to the Internet. If it is accessible over the Internet, it is open to malicious actors and needs to be secured.
There is no point implementing a tunnel to your MySQL database if it's accessible directly over the Internet.
cloudflared
and MySQL Docker containers: npm run cloudflared
. npm run dev
globalThis
ERROR: CF_CLIENT_ID is not defined. Attempted to access binding using global in modules. You must use the 2nd env parameter passed to exported handlers/Durable Object constructors, or context.env with Pages Functions.
When using Module Workers, do not use the globalThis
variable.
This restrictions was introduced in this commit on Miniflare.
Learn the difference between Service Workers and Module Workers on this blog post.
// Module Worker format
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
globalThis.CF_CLIENT_ID = env.CF_CLIENT_ID || undefined
console.log(globalThis.CF_CLIENT_ID) // <-- will error!
}
}
// Module Worker format
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
console.log(env.CF_CLIENT_ID) // Use `env` directly and pass through app.
}
}
✘ [ERROR] Top-level await is not available in the configured target environment ("es2020")
The original worker-mysql no longer works with top-level awaits (TLAs). The TLAs need to be wrapped.
await setup(DEFAULT_CONFIG);
(async () => {
await setup(DEFAULT_CONFIG);
})();
127.0.0.1
cloudflared-tunnel | 2022-11-09T16:43:47Z ERR error="dial tcp 127.0.0.1:3306: connect: connection refused" cfRay=767802675b1472e2-LHR ingressRule=0 originService=tcp://127.0.0.1:3306
or
localhost
cloudflared-tunnel | 2022-11-09T16:43:47Z ERR error="dial tcp localhost:3306: connect: connection refused" cfRay=767802675b1472e2-LHR ingressRule=0 originService=tcp://localhost:3306
When cloudflared
and MySQL are both running within a Docker container, the IP address for cloudflared
to reach the MySQL container is Docker's internal IP address, accessible via the hostname host.docker.internal
.
Ensure the Public Hostname for the tunnel is pointing to an IP/hostname accessible to cloudflared
. Cloudflare Zero Trust dashboard under "Access" > "Tunnels" > your tunnel > "Public Hostname".
ERR Request failed error="dial tcp 127.0.0.1:3306: connect: connection refused" connIndex=0 dest=<HOST_NAME> ip=<IP_ADDRESS> type=ws
By default, Docker containers cannot make outbound network calls to host (127.0.0.1) ports, therefore Cloudflare cannot reach MySQL running on the host. Setup Cloudflared to run on the same network as the host by adding network_mode: host
to the docker-compose.yml file (see commented out line).
Feel free to contribute through PR and raising issues.