Automating Remote Windows Tasks with SSH + Task Scheduler
A powerful machine in another room is one of the most useful resources I have if I can control it from my regular workstation. Windows now includes OpenSSH Server, but once I actually connect over ssh, the other end turns out not to be Linux, and my command-line habits break from the very first line.
I had a batch of heavy processing work to do, converting thousands of files at a time. Every time I ran it on the laptop I work on, it got so hot that I could not do anything else all day. Meanwhile, the workstation in another room, with a CPU and GPU several times more powerful, was sitting idle. I decided to move the whole workload there and control it from the machine I normally work on.
Windows 10/11 now includes OpenSSH Server. There is no need to install anything third-party; two commands enable it. But once I actually connected over ssh, I realized all the obstacles came after that. This article records the pitfalls I ran into myself. All IP addresses, machine names, and key filenames in the article have been replaced with example values. The structure and commands are the real ones.
Enable SSH Server on Windows
Run this in PowerShell as admin on the target machine:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
New-NetFirewallRule -Name sshd -DisplayName "OpenSSH Server" `
-Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
On my regular workstation, I set up an alias in ~/.ssh/config to save typing:
Host winbox
HostName 192.168.10.50
User workuser
IdentityFile ~/.ssh/id_ed25519
If the target account belongs to the Administrators group, Windows sshd will not read C:\Users\ชื่อ\.ssh\authorized_keys. It reads C:\ProgramData\ssh\administrators_authorized_keys instead, and that file must grant access only to Administrators and SYSTEM. Otherwise, it silently keeps asking for a password without explaining why. Almost every case of "I added the key, but it still asks for a password" comes down to this.
Pitfall 1: The Other End Is Not bash, or PowerShell 7 Either
The default shell for sshd on Windows is cmd.exe, and if I configure it to use PowerShell, I get Windows PowerShell 5.1, the version bundled with the OS, rather than the PowerShell 7 I installed separately. Command-line habits carried over from Linux break from the very first command. I lost time to this several times before writing a table to keep beside my screen.
| Don't use (habits from Linux) | Use this on PS 5.1 instead |
|---|---|
cmd1 && cmd2 |
cmd1; cmd2 |
~ or $HOME |
$env:USERPROFILE or the full C:\... path |
2>/dev/null |
2>$null |
head / tail |
Select-Object -First N / Get-Content -Tail N |
cat file | grep x |
Select-String x file |
which cmd |
(Get-Command cmd).Source |
mkdir -p |
New-Item -ItemType Directory -Force |
emoji in print() |
ASCII only |
That last row is not about appearance. PS 5.1 uses code page cp1252 by default. A Python script that calls print() with characters outside that range throws a UnicodeEncodeError, and the job dies halfway through even though nothing is wrong with the actual processing. I was baffled the first time I hit this, because the same script always ran successfully on my own machine.
Running a Python script over ssh therefore looks something like this:
ssh winbox "conda activate ml313; python C:\jobs\convert.py --batch 200"
To keep a job running after disconnecting ssh, I need to launch it detached; otherwise, it dies when the session closes:
ssh winbox "Start-Process -WindowStyle Hidden 'C:\Users\workuser\miniconda3\envs\ml313\python.exe' -ArgumentList 'C:\jobs\convert.py'"
The example I wrote above was wrong. Start-Process -WindowStyle Hidden only separates the job from the console window, but Windows sshd still keeps all child processes in the same job object. When the session closes, long-running jobs still die with it. The jobs I had seen survive were simply short enough to finish first. To actually keep a long-running job alive, use Task Scheduler as described in the next section of this article, or launch the process with the breakaway from job flag.
Pitfall 2: Always Use a .bat File When Scheduling with schtasks
Once I could run commands manually, it was time to automate them. On Windows, I use schtasks, the CLI for Task Scheduler. The problem is that cramming a long command and its arguments onto one line is quoting hell — quotes nested inside quotes passing through two shells. After several failed attempts, I gave up and changed my approach.
The most reliable approach is to write a .bat wrapper on the target machine, then have schtasks call that one file:
@echo off
"C:\Users\workuser\miniconda3\envs\ml313\python.exe" "C:\jobs\convert.py" >> "C:\jobs\run.log" 2>&1
I once skipped scp out of laziness and used echo to write the text directly into a file. The sending shell swallowed the backslashes in the paths as escape sequences — \envs became nvs, and the entire \Users segment disappeared. I ended up with a .bat file whose broken paths were hard to spot because they looked almost right, and the job silently failed every night without a visible error. Always finish writing the file first, then upload it with scp. Do not assemble files through command lines across machines.
Upload the file, then create the task — note that scp needs forward slashes and a full path starting with C:/; ~ does not work:
scp ./run.bat "winbox:C:/jobs/run.bat"
ssh winbox "schtasks /Create /TN ConvertBatch /TR 'C:\jobs\run.bat' /SC DAILY /ST 23:30 /F"
ssh winbox "schtasks /Run /TN ConvertBatch" # ยิงทดสอบเดี๋ยวนั้น
ssh winbox "schtasks /Query /TN ConvertBatch /FO LIST"
For jobs that need to repeat within a time window, such as every 30 minutes between six in the evening and midnight, use /RI together with /DU:
ssh winbox "schtasks /Create /TN ConvertBatch /TR 'C:\jobs\run.bat' /SC DAILY /ST 18:00 /RI 30 /DU 0006:00 /F"
A note on permissions — Tasks created this way run in the context of the logged-in account. If the screen is locked or nobody remains logged in, tasks that need a display (opening a browser with a visible window, taking screenshots) will not work. Pure computation jobs are fine. I therefore make a clear distinction during design between jobs that need an actual desktop and those that do not.
Pitfall 3: Sending Results to the Server Hangs
This was the issue that took up the most time on this project.
At the end of each run, the script on the target machine needs to send its results to a server for storage. I wrote it the simplest way: calling OpenSSH's ssh/scp through Python's subprocess. It ran perfectly when I launched it manually in an ssh session, but when Task Scheduler launched it, it hung until it timed out, every time. No errors, nothing in the logs on either end.
Things I tried that did not help at all: adding -o BatchMode=yes, feeding empty stdin to prevent it from waiting for input, adding -o StrictHostKeyChecking=no, and placing known_hosts in the appropriate locations for both SYSTEM and the user account. It still hung every time. The symptom was Windows OpenSSH waiting for a console that did not exist in the scheduled task's context.
The solution that actually worked was to stop using OpenSSH for this part and switch to PuTTY's plink in batch mode with an explicitly specified host key:
import subprocess
PLINK = r"C:\jobs\plink.exe"
KEY = r"C:\jobs\<KEY>.ppk"
HOSTKEY = "SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
def push(payload: bytes) -> None:
cmd = [PLINK, "-ssh", "-P", "2233", "-i", KEY,
"-hostkey", HOSTKEY, "-batch",
"[email protected]",
"mysql --default-character-set=utf8mb4 -u USER -pPASS DBNAME"]
r = subprocess.run(cmd, input=payload, capture_output=True, timeout=90)
if r.returncode != 0:
raise RuntimeError(r.stderr.decode("utf-8", "replace"))
Hangs without a console
It always works when run manually over ssh, but when Task Scheduler launches it, it hangs until the timeout. There is no message explaining why, and checking the logs on both ends reveals nothing.
No prompts at all
Batch mode disables all prompts, while -hostkey supplies the server's fingerprint directly, removing any dependence on any account's known_hosts. It works both when run manually and when launched by the scheduler.
The reason for specifying -hostkey is that batch mode will not ask for confirmation of the target machine's key. If it encounters an unknown key, it prints "Cannot confirm a host key in batch mode" and exits immediately. To get the value, run plink manually once. It prints SHA256:...; use that value, and that is it. The key file must first be converted to .ppk, because plink does not directly accept OpenSSH's format.
Pitfall 4: Thai Text Turns into Gibberish
Once the pipeline was running, all the Thai text in the data sent to the server turned out to be corrupted. The cause was that same subprocess call.
With text=True, Python encodes stdin using the system locale, which on PS 5.1 is cp1252 — Thai characters do not exist in cp1252, so they turn into question marks or strange characters before even leaving the machine. The fix is to send UTF-8 bytes explicitly instead of letting Python guess:
# ผิด - ให้ระบบเลือก encoding ให้ตาม locale (cp1252)
subprocess.run(cmd, input=payload_str, text=True)
# ถูก - บังคับเป็น UTF-8 ไบต์ตรงๆ
subprocess.run(cmd, input=payload_str.encode("utf-8"), capture_output=True)
The receiving end also needs explicit settings, or everything falls apart at the last step — when connecting to MySQL, include --default-character-set=utf8mb4 and start every batch of statements with SET NAMES utf8mb4;.
At every point where data crosses a boundary between systems, always specify the encoding explicitly. Do not let the defaults choose. Worse than garbled text is text that gets corrupted silently, with no visible error. By the time I noticed, several days of corrupted data had already accumulated in the database.
Summary
The setup I use today is very simple: my workstation sends commands over ssh when I need something run immediately, while Task Scheduler handles recurring jobs by calling a single .bat file on schedule. Sending results out uses plink in batch mode, without touching OpenSSH. Everything is logged to one file on the target machine.
My own takeaways come down to three things. First — do not assume that connecting to Windows over ssh makes it Linux; accept that it is PowerShell 5.1 and write commands by its rules. Second — if something is a file, write it as a file and upload it; do not assemble it through command lines across machines. Third — a tool that works when I run it manually will not necessarily work when the scheduler calls it; always test in the actual context.
If there is a powerful machine sitting idle in another room, try moving the heavy work there when it suits you. It is far more worthwhile than it might seem. If I have left anything out or made any typos, I will come back and correct them later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first