Migrating a Python automation stack between Windows machines looks trivial until it isn't. Nine scheduled tasks, a blog pipeline, a trading system, and a few hundred state files moved from a Windows 11 dev box (UTF-8 default) to a fresh Windows Server 2016 instance (GBK default). The scripts were identical. The behavior was not. Here is the package approach that worked, including the encoding trap that cost the most time.
Why "just copy the folder" fails
The dev box had accumulated state that should not travel: logs, built site output, trade records, session cookies, and hundreds of JSON state files that referenced absolute paths from the old machine. Copying everything would have dragged stale data onto the server, and in the trading system's case, replayed old positions into a fresh account.
The other problem is that scheduled tasks store absolute paths. A task registered on C:\Users\dev\... will not resolve on a server where the user profile is C:\Users\Administrator\.... Re-registering tasks by hand across nine entries is a recipe for typos, so the migration needed to be scripted end to end.
The package layout
I built a single migration/ folder that could be copied to the new machine and run without editing:
migration/
payload/ # the actual code, minus runtime data
exclude.txt # xcopy exclusion list
install.bat # one-click installer
register_tasks.ps1 # re-registers all scheduled tasks
verify.bat # checks every task's action and last result
requirements.txt
The payload/ directory is the source tree with runtime directories stripped out. Everything machine-specific is resolved at install time.
xcopy with an exclusion list
robocopy is the better tool for large trees, but xcopy with /EXCLUDE is simpler to reason about when you want a flat file list and predictable behavior on older Windows Server builds. The exclude file protects runtime data:
\logs\
\site\build\
\data\trades\
\data\cookies\
\state\*.tmp
\__pycache__\
*.pyc
.env
Then the copy is one line:
xcopy "%~dp0payload" "%USERPROFILE%\automation" /E /I /Y /EXCLUDE:%~dp0exclude.txt
/E copies empty directories, /I assumes the destination is a directory if it doesn't exist, /Y suppresses the overwrite prompt. The exclude file uses substring matching, so \logs\ catches any path segment named logs anywhere in the tree.
One caveat: xcopy's exclude matching is greedy and case-insensitive on Windows. If you have a source directory named Logs and a legitimate catalog directory, the pattern \logs\ will not match catalog because it requires the surrounding backslashes, but a bare logs pattern would. Keep the delimiters.
One-click installer that auto-detects paths
The installer resolves everything relative to %USERPROFILE% so the same batch file works on any account:
@echo off
setlocal
set "ROOT=%USERPROFILE%\automation"
set "PY=%LOCALAPPDATA%\Programs\Python\Python311\python.exe"
if not exist "%PY%" (
echo Python not found at %PY%
exit /b 1
)
echo Installing to %ROOT%
xcopy "%~dp0payload" "%ROOT%" /E /I /Y /EXCLUDE:%~dp0exclude.txt
if errorlevel 1 (
echo Copy failed
exit /b 1
)
"%PY%" -m pip install -r "%~dp0requirements.txt" --quiet
if errorlevel 1 (
echo Dependency install failed
exit /b 1
)
powershell -ExecutionPolicy Bypass -File "%~dp0register_tasks.ps1" -Root "%ROOT%"
if errorlevel 1 (
echo Task registration failed
exit /b 1
)
echo Install complete
endlocal
The %~dp0 prefix resolves to the directory containing the batch file, so the package can live anywhere. The installer fails fast at each stage instead of continuing with a broken environment.
Re-registering scheduled tasks
Nine tasks needed to move from the old machine to the new one. Rather than export XML and rewrite paths, I defined the tasks in a PowerShell array and used New-ScheduledTaskAction plus Register-ScheduledTask. This keeps the task definitions in source control and makes the paths parametric:
param(
[Parameter(Mandatory=$true)]
[string]$Root
)
$python = Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"
$tasks = @(
@{ Name = "BlogPublish"; Script = "blog\publish.py"; Time = "06:00" },
@{ Name = "BlogFetch"; Script = "blog\fetch.py"; Time = "06:30" },
@{ Name = "TradeScan"; Script = "trading\scan.py"; Time = "09:00" },
@{ Name = "TradeExecute"; Script = "trading\execute.py"; Time = "09:15" },
@{ Name = "TradeReport"; Script = "trading\report.py"; Time = "16:00" },
@{ Name = "StateBackup"; Script = "ops\backup.py"; Time = "23:00" },
@{ Name = "HealthCheck"; Script = "ops\health.py"; Time = "00:00" },
@{ Name = "LogRotate"; Script = "ops\rotate.py"; Time = "00:30" },
@{ Name = "DailyDigest"; Script = "ops\digest.py"; Time = "07:00" }
)
foreach ($t in $tasks) {
$scriptPath = Join-Path $Root $t.Script
$action = New-ScheduledTaskAction `
-Execute $python `
-Argument "`"$scriptPath`"" `
-WorkingDirectory (Split-Path $scriptPath)
$trigger = New-ScheduledTaskTrigger -Daily -At $t.Time
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask `
-TaskName $t.Name `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-User $env:USERNAME `
-RunLevel Highest `
-Force
}
Write-Host "Registered $($tasks.Count) tasks"
-StartWhenAvailable matters on a server that may be asleep or rebooting at the trigger time. -ExecutionTimeLimit prevents a hung script from blocking the next day's run. -Force makes the script idempotent, so re-running it after a code update just overwrites the existing task.
The encoding trap
This is where the migration nearly failed. The dev box ran Windows 11 with UTF-8 as the default code page. The server ran Windows Server 2016 with GBK (code page 936). The same Python scripts produced different output on each machine.
The symptoms were subtle. A blog post with a curly apostrophe rendered as ’ on the server. A trading log line with a Chinese instrument name raised UnicodeEncodeError when redirected to a file. A JSON state file written on the dev box loaded fine on the server, but a state file written on the server and read back on the dev box returned mojibake.
The root cause: Python 3.7+ uses locale.getpreferredencoding() for open() when no encoding is specified, and sys.stdout on Windows uses the console code page unless redirected. On the dev box that was UTF-8. On the server it was GBK. Every unqualified open() and every print() was a latent bug.
The fix is to pin encoding everywhere. No exceptions.
Pinning file I/O
import json
from pathlib import Path
def read_json(path: Path) -> dict:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def write_json(path: Path, data: dict) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8", newline="\n") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
tmp.replace(path)
The newline="\n" matters when the same files are read by tools on other platforms. ensure_ascii=False keeps non-ASCII characters readable in the file instead of escaping them, which is fine as long as the encoding is pinned on both ends.
Pinning stdout
Redirected stdout on Windows inherits the console code page, which is why logging to a file behaved differently from logging to the terminal. Reconfigure the streams at process start:
import sys
def force_utf8_streams() -> None:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
force_utf8_streams()
Call this before any imports that might log at module level. errors="replace" prevents a single bad byte from killing a long-running task; log the replacement character and move on.
Pinning subprocess output
Any subprocess.run that captures text inherits the same problem. Pass the encoding explicitly:
import subprocess
result = subprocess.run(
["git", "log", "-1", "--format=%s"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
Without encoding="utf-8", Python decodes the child process output using the locale code page. On the server that meant GBK decoding of UTF-8 bytes, which silently produced garbage in commit messages.
Pinning the environment
For belt and braces, set PYTHONUTF8=1 in the task environment. Python 3.7+ honors this and enables UTF-8 mode globally, which makes open() default to UTF-8 and sys.stdout use UTF-8 regardless of the console code page:
$env:PYTHONUTF8 = "1"
I set this in the scheduled task definition via the -Environment parameter on New-ScheduledTaskAction where supported, and as a system environment variable on the server otherwise. It does not replace explicit encoding arguments in code, but it catches anything I missed.
The verification bat
After install, verify.bat checks every task's action and last run result. This caught two tasks that had been registered with the wrong Python path and one that had never run because its trigger time had already passed on the install day:
@echo off
setlocal enabledelayedexpansion
set "FAIL=0"
for %%T in (
BlogPublish BlogFetch TradeScan TradeExecute
TradeReport StateBackup HealthCheck LogRotate DailyDigest
) do (
for /f "delims=" %%A in ('powershell -NoProfile -Command ^
"(Get-ScheduledTask -TaskName '%%T').Actions[0].Execute"') do (
set "EXEC=%%A"
)
for /f "delims=" %%R in ('powershell -NoProfile -Command ^
"(Get-ScheduledTaskInfo -TaskName '%%T').LastTaskResult"') do (
set "RESULT=%%R"
)
echo %%T exec=!EXEC! last=!RESULT!
if "!EXEC!"=="" (
echo MISSING ACTION
set "FAIL=1"
)
if not "!RESULT!"=="0" if not "!RESULT!"=="267009" (
echo NONZERO RESULT
set "FAIL=1"
)
)
if "!FAIL!"=="1" (
echo Verification failed
exit /b 1
)
echo All tasks verified
endlocal
267009 is the "task is currently running" result code, which is not a failure. Any other nonzero LastTaskResult gets flagged. The action check catches the case where a task exists but points at a Python interpreter that was uninstalled or moved.
What I would do differently
Two things. First, set PYTHONUTF8=1 on both machines before writing any code, not after the first mojibake appears. Second, treat the exclude list as part of the source tree and review it whenever a new runtime directory is added; a stale exclude list is how you accidentally ship a trade record to a new machine.
The migration itself ran in about ten minutes once the package was built. Building the package took a day, most of it spent on the encoding audit. The scripts were never the problem. The environment was.
More notes like this ship every week on this site.
Daily Picks
The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-06 12:36:33
Long
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| SKYAI | $0.0416 | $0.0433 | $0.0406 | 1:1.6 | |
| RE | $0.4991 | $0.5181 | $0.4866 | 1:1.5 |
2 picks selected. Scanner runs every 15 minutes.
Top comments (0)