Cloudflare Tunnel as a Windows scheduled task (and the visible-console bug I shipped)

I needed a Cloudflare tunnel to survive reboots without me babysitting it, so I wired it up to autostart and walked away. Then I noticed a black console window flashing open every time I logged into the machine. The tunnel worked fine. The window was a bug I had shipped to myself, and the fix was less obvious than the original setup.

This is the whole story: why I picked a scheduled task over a Windows service for a reason that has nothing to do with reliability, the visible-console mistake that came with that choice, and the verification step I almost skipped.

The setup: a tunnel that has to outlive my login

The context is a small one. I run a private vault on this desktop, and I wanted external AI fetchers (GPT, Claude, Manus) to be able to pull a single curated markdown file from it over a public URL. That meant standing up a Cloudflare tunnel, vault.dxdev.com, pointed at a local Express server on localhost:3001. cloudflared version 2025.8.1, config in vault-portal.yml, tunnel id 9272c146-....

Cloudflare’s own docs push you toward installing cloudflared as a Windows service. That’s the default answer for “make it survive reboots,” and on the reliability axis it’s a fine answer. A service starts at boot, before anyone logs in, and the Service Control Manager restarts it if it dies.

I went with a logon-triggered scheduled task instead. Not because the service is unreliable. Because of where the service keeps its config.

The non-obvious reason: SYSTEM vs your user profile

When cloudflared runs as a Windows service, it runs as LocalSystem (SYSTEM). SYSTEM has its own user profile, and its .cloudflared directory lives under:

C:\Windows\System32\config\systemprofile\.cloudflared\

That path is under System32. You cannot write to it from a normal editor session. Every time you want to touch that config, Windows makes you elevate.

Here’s the part that bit me in my head before it bit me in practice: a tunnel config is not write-once. The “do one UAC prompt at install and you’re done forever” framing is wrong for anything that evolves. I add hostnames. I tweak ingress rules. I rotate credentials. Each of those is a config edit, and if the config lives under systemprofile, each of those edits re-engages UAC. Forever. The service model trades one install-time elevation for an elevation tax on every future change.

A scheduled task running as my own user keeps the config where my user profile already is:

C:\Users\<username>\.cloudflared\

Zero UAC to edit it now. Zero UAC to edit it next month. The config lives in a directory I already own, so editing it is just editing a file.

That is the actual deciding factor. Reliability was a wash between the two. The tiebreaker was “which one makes every future config edit free,” and the scheduled task wins that outright when the config is going to keep changing. If your tunnel config is genuinely static and you will never touch it again, the service is fine and arguably cleaner. Mine was never going to be static.

Registering the task

This is straight PowerShell, no XML hand-editing. Action, trigger, settings, register:

Terminal window
# First pass pointed the action straight at cloudflared. That's what shipped the visible-window bug below.
$action = New-ScheduledTaskAction -Execute 'C:\Program Files (x86)\cloudflared\cloudflared.exe' -Argument 'tunnel --config C:\Users\<username>\.cloudflared\vault-portal.yml run'
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -RestartInterval (New-TimeSpan -Minutes 1) -RestartCount 5

The -AtLogOn trigger fires the task when I log in. The -RestartInterval 1m -RestartCount 5 part is the piece that buys back the auto-restart-on-crash behavior the service would have given me for free: if cloudflared dies, the task scheduler relaunches it, up to five times at one-minute intervals. That covers the crash case without putting the config back under SYSTEM.

The bug I shipped: a console window on every login

I registered the task with -LogonType Interactive. That seemed right. The task runs as me, interactively, when I log on.

What Interactive actually does is run the process attached to my interactive desktop session. cloudflared is a console application. So every single login spawned a visible cloudflared console window. Not a flash. A window, sitting there, that I had to look at or minimize.

I didn’t catch it from a log or a monitor. I caught it because the running tunnel showed up as a console window in my own session and I went “what is that.” Which is its own small lesson: the thing that surfaced the bug was just using the machine the way I normally use it. Autostart bugs hide until the next boot, and then they’re staring at you.

The fix: a hidden VBS shim

The fix is an old Windows trick that’s still the cleanest way to launch a console process with no window. Wrap the launch in a VBScript shim and run it through wscript.exe, which (unlike cscript.exe) has no console of its own, and tell the shell to run the child with window style 0 (hidden).

The one thing a hidden window costs you is the output. A visible console is a debugging surface, however ugly. Hide it and you’ve thrown away cloudflared’s stdout and stderr. So I have the shim launch cloudflared through cmd /c and append both streams to a log file in the same line. run-tunnel.vbs:

Set WshShell = CreateObject("WScript.Shell")
' 0 = hidden window, False = don't wait for it to exit.
' cmd /c does the >> redirect so we keep stdout+stderr even with no console.
WshShell.Run "cmd /c """"C:\Program Files (x86)\cloudflared\cloudflared.exe"" tunnel --config ""C:\Users\<username>\.cloudflared\vault-portal.yml"" run >> ""C:\Users\<username>\.cloudflared\tunnel.log"" 2>&1""", 0, False

Then point the scheduled task’s action at wscript.exe run-tunnel.vbs instead of at cloudflared directly. Now nothing visible spawns at logon, and both streams land in:

C:\Users\<username>\.cloudflared\tunnel.log

Invisible at logon, fully debuggable when something breaks. That’s the trade you want: no window in your face, all the output still on disk.

Don’t call it done until the process belongs to the task

There’s a failure mode that makes this whole thing look finished when it isn’t. You register the task, you also have cloudflared running in a foreground terminal from when you were testing, and the public URL returns 200. Looks shipped. Except the 200 is coming from your foreground process, and the task has never actually launched a working tunnel. The moment you close the terminal, or reboot, it dies.

So the verification has to be specific:

  1. Kill the foreground cloudflared process completely.
  2. Fire the task (don’t wait for the next logon, trigger it manually).
  3. Confirm the new cloudflared PID actually belongs to the task’s process tree, not a stray leftover.
  4. Only then curl the public URL and watch for the 200.

That ordering matters. If you curl before you kill the foreground process, you’re testing the wrong process and you’ll declare victory on a tunnel that won’t survive a reboot.

One more thing this exposed, and it’s worth naming because it’s exactly the kind of thing that sends you debugging the wrong layer. Earlier in the same session, before any of the scheduled-task work, the tunnel was returning Cloudflare 530 / error 1033 for everything I hit, the share route, the existing markdown route, the bare domain. The instinct is to assume your newest change broke it. It hadn’t. 530/1033 is Cloudflare telling you the origin behind the tunnel is unreachable, which meant the tunnel itself was down, a pre-existing condition that had nothing to do with the route code I’d just written. Isolating that as “tunnel down, not my new code” before touching anything saved me from “fixing” a route that was already correct.

The takeaway

For a long-running dev tunnel on Windows whose config is going to keep changing, a logon-triggered scheduled task beats a Windows service. Not for reliability, that’s a wash once you add -RestartInterval/-RestartCount. It beats it because a service runs as SYSTEM with its config buried under systemprofile, so every future edit re-engages UAC, while a task keeps the config in your own user profile with zero elevation, now and ever.

But the scheduled task has a trap the service doesn’t: -LogonType Interactive pops a visible console window at every login. Wrap the launch in a hidden VBS shim that redirects stdout and stderr to a log file, so it’s invisible but still debuggable. And before you call any of it done, kill the foreground process, fire the task, and verify the running PID actually belongs to the task’s tree. A 200 from the wrong process is not a working autostart.