Skip to content

How To: Protect a PyInstaller Application

PyInstaller is the most popular Python packaging tool. PyLocket supports both --onefile and --onedir modes.


Prerequisites

  • PyLocket CLI installed and authenticated (pylocket login)
  • An app registered with PyLocket (pylocket apps create)
  • PyInstaller installed (pip install pyinstaller)

The correct PyInstaller mode depends on the target platform:

Platform PyInstaller Flags Output Why
macOS --onedir --windowed .app Produces a proper .app bundle with Contents/Frameworks/Python.framework/ physically present (~6.5 MB dylib). The .app appears as a single file in Finder and is the standard format for macOS distribution.
Windows --onefile --windowed .exe Single .exe with the Python runtime and all DLLs compressed inside. Extracts to a temp directory at launch.
Linux --onefile binary Single self-contained binary. Same extraction behaviour as Windows.

Do not use --onefile on macOS

--onefile on macOS compresses everything — including the Python runtime and all native libraries — into a binary blob inside the executable. The resulting .app bundle does not contain Contents/Frameworks/Python.framework/ as a physical directory. Python is buried inside the compressed executable and unusable by the system. This produces a non-standard .app that can cause code-signature failures and "damaged" errors after protection. Always use --onedir --windowed for macOS.


Build

Use the platform-specific flags from the Recommended Build Mode table above:

pyinstaller --onefile --windowed myapp.py

Output: dist/myapp.exe

pyinstaller --onedir --windowed --target-arch universal2 myapp.py

Output: dist/MyApp.app (a complete .app bundle with Python.framework in Contents/Frameworks/)

PyInstaller 6+ .app bundles use symlinks — keep them

Starting with PyInstaller 6, macOS .app bundles are built with symbolic links: for example Contents/Frameworks/Python.framework/Python is a symlink to Versions/Current/Python, and PyInstaller cross-links each top-level entry into Contents/Resources/. You must preserve those symlinks when uploading — flattening them into real files bloats the upload (roughly 2×) and can produce a duplicate Python.framework that breaks codesign --deep --strict.

Best — let the CLI do it. pylocket protect on the .app directory archives it symlink-preserving automatically, so there is no manual step:

pyinstaller --onedir --windowed --target-arch universal2 myapp.py
pylocket protect --app <APP_ID> --artifact dist/MyApp.app --platform macos-universal

Manual / portal upload — use ditto (Apple's archiver, which preserves symlinks and bundle structure; a plain zip -r or Finder "Compress" does not):

ditto -c -k --keepParent dist/MyApp.app dist/MyApp.app.zip
# then upload dist/MyApp.app.zip in the portal

Quick check

Confirm your .app still has its symlinks (it should):

find dist/MyApp.app -type l | head

If this prints nothing, your build tool stripped the symlinks — rebuild or re-archive with ditto/the CLI.

Avoid rsync -aL (and other symlink-flattening steps)

Older guidance suggested rsync -aL dist/MyApp.app/ dist/MyApp_Standalone.app/ to sidestep a FileExistsError: Versions/Current/Resources seen with some zip tools. Prefer the CLI or ditto above instead — they handle the symlinks correctly and produce a smaller upload. PyLocket now repairs a flattened bundle server-side, so an rsync -aL upload still protects, but it wastes bandwidth and is no longer the recommended path.

pyinstaller --onefile myapp.py

Output: dist/myapp (a platform-native binary)

Organise your build outputs so each platform artifact lands in a dedicated folder. This makes it easy to upload each one to PyLocket as a separate build:

dist/
  PyLocket/
    Windows/
      MyApp.exe        # PyInstaller --onefile (Windows)
    MacOS/
      MyApp.app/       # PyInstaller --onedir --windowed (recommended)
        Contents/
          MacOS/
            MyApp      # Main executable
          Frameworks/
            Python.framework/  # Bundled Python runtime
          Resources/
            icon.icns
          Info.plist
    Linux/
      myapp.elf         # PyInstaller --onefile (Linux), renamed from the extensionless "myapp"

Your build.py (or CI script) can produce this layout by passing --distpath dist/PyLocket/Windows (etc.) to PyInstaller.

Protect

Upload the raw executable for each platform. Do not upload installer packages (.msi, .dmg, Inno Setup .exe, NSIS .exe). Upload the executable inside the installer instead.

# Windows
pylocket protect --app <APP_ID> --artifact dist/PyLocket/Windows/MyApp.exe --platform win-x64

# macOS (.app bundle from --onedir --windowed)
pylocket protect --app <APP_ID> --artifact dist/PyLocket/MacOS/MyApp.app --platform macos-universal

# Linux (rename the extensionless binary to .elf first)
mv dist/PyLocket/Linux/myapp dist/PyLocket/Linux/myapp.elf
pylocket protect --app <APP_ID> --artifact dist/PyLocket/Linux/myapp.elf --platform linux-x64

Note

PyInstaller's Linux --onefile output has no file extension, and the upload validator rejects extensionless files. Rename it to .elf before uploading; the name is only a transport convention, and the engine reads the real format from the file contents.

Download

pylocket fetch --build <BUILD_ID> --out dist/protected/

Output Structure

dist/protected/
├── myapp.exe              # (or MyApp.app / myapp.elf)
├── <native runtime>       # Platform-specific runtime library
└── <protection manifest>  # Signed protection manifest

Code Signing (macOS)

Protection invalidates the original code signature because the inner binary is modified. PyLocket strips the invalid signature so macOS shows "unidentified developer" rather than "damaged and can't be opened".

After downloading the protected .app, re-sign it with your Apple Developer ID certificate:

codesign --force --deep --sign "Developer ID Application: Your Name (TEAMID)" MyApp.app

If you distribute outside the Mac App Store, also notarize the app:

# Create a ZIP for notarization
ditto -c -k --keepParent MyApp.app MyApp.app.zip

# Submit for notarization
xcrun notarytool submit MyApp.app.zip \
  --apple-id you@example.com \
  --team-id TEAMID \
  --password @keychain:notarytool \
  --wait

# Staple the ticket to the app
xcrun stapler staple MyApp.app

PyLocket never handles your signing identity

Re-signing requires your private key (certificate). This step must be performed on your machine or in your CI pipeline after downloading the protected artifact. PyLocket does not store, transmit, or access your code-signing credentials.

Automate in CI

Add the re-signing step to your CI/CD pipeline after pylocket fetch:

pylocket fetch --build $BUILD_ID --out dist/protected/
codesign --force --deep --sign "$APPLE_SIGNING_IDENTITY" dist/protected/MyApp.app

macOS Universal2 Builds

The --target-arch universal2 flag produces a single binary that runs natively on both Intel and Apple Silicon Macs. This is the recommended approach for macOS distribution.

Universal2 Dependency Verification

All native dependencies (.dylib, .so files) must have universal2 builds available. If any dependency is single-architecture only, PyInstaller will either fail or silently fall back to a single-architecture build.

Prerequisites:

  1. Your Python interpreter must be universal2 (the standard python.org installer is universal2; Homebrew Python 3.10+ also supports it).
  2. All native-extension packages must ship universal2 wheels on PyPI.

Two-step install for universal2 dependencies:

# Step 1: Install everything normally (arm64 wheels) into a clean venv
pip install -r requirements.txt

# Step 2: Overwrite native-extension packages with universal2 builds
pip install \
  --platform macosx_11_0_universal2 \
  --target "$(python -c 'import site; print(site.getsitepackages()[0])')" \
  --no-deps \
  <native-package-1> <native-package-2>

Replace <native-package-1> etc. with the names of packages that contain .so or .dylib files (e.g. numpy, cryptography, pillow).

Check before building

Run file dist/MyApp.app/Contents/MacOS/MyApp after building. A universal2 binary will show both x86_64 and arm64 architectures.


Using a .spec File

If you use a .spec file for custom PyInstaller configuration, the workflow is the same — just build with the spec file first:

pyinstaller myapp.spec

Then protect the output as shown above.


Common Options

Hidden Imports

If your application uses hidden imports (common with dynamic imports, plugins, etc.), ensure they are included in your PyInstaller build before protecting:

pyinstaller --onefile --hidden-import=my_module myapp.py

PyLocket protects whatever PyInstaller packages. If a module is missing from the PyInstaller output, it will also be missing from the protected output.

Data Files

Non-Python data files (images, configs, etc.) bundled by PyInstaller are not encrypted by PyLocket — only Python bytecode is protected. If you need to protect data files, encrypt them separately at the application level.

Console vs. Windowed

Both --console and --windowed (or --noconsole) modes work with PyLocket:

# Console app
pyinstaller --onefile myapp.py

# Windowed app (no console window)
pyinstaller --onefile --windowed myapp.py

macOS .app bundles require --windowed

On macOS, --windowed is required to produce a .app bundle. Without it, PyInstaller outputs a bare binary instead.


Troubleshooting

Problem Solution
Unsupported artifact type Ensure you are uploading the .exe (Windows), the binary renamed to .elf (Linux), the .app bundle (macOS), or a .zip of the onedir output
Missing modules at runtime Add --hidden-import flags to your PyInstaller command
Build rejected: "Flagged by N of M AV engines" Usually a false positive. A PyInstaller executable bundles an interpreter and unpacks itself at launch, which is the shape ML engines are trained to flag, so they flag the packaging rather than your code. See Clear an antivirus false positive. Code signing is the permanent fix.
Large artifact upload timeout For artifacts over 100 MB, ensure a stable connection. The CLI retries up to 3 times automatically.
Protected app crashes on startup If you passed --python, verify it matches your build environment. The flag is optional; when omitted, the engine detects the Python version from the artifact itself.
ModuleNotFoundError: No module named '_pylocket_bootstrap' raised from your own runtime hook Fixed in engine 1.0.0.0.0.125. Apps that use a custom --runtime-hook (some frameworks, such as Panda3D, add one for you) hit this on older engines. Upload the build again to re-protect it on the current engine. No change to your build command is needed.
macOS: "damaged and can't be opened" The .app has an invalid code signature. Right-click the app and select Open, or run xattr -cr MyApp.app in Terminal.
macOS: empty Frameworks/ directory You used --onefile mode. Rebuild with --onedir --windowed for a complete .app bundle with Python.framework.
macOS: FileExistsError: File exists: 'Versions/Current/...' Your .app contains symbolic links (PyInstaller 6+). Create a portable copy with rsync -aL YourApp.app/ YourApp_Standalone.app/ and upload that instead. See PyInstaller 6+ symlinks above.

See Also