DEV Community

mattleeee
mattleeee

Posted on Originally published at hkcode.dpdns.org

Migrating a Python Automation Stack to a New Windows Server

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then the copy is one line:

xcopy "%~dp0payload" "%USERPROFILE%\automation" /E /I /Y /EXCLUDE:%~dp0exclude.txt
Enter fullscreen mode Exit fullscreen mode

/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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

-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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)