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+ creates symlinked .app bundles

Starting with PyInstaller 6, macOS .app bundles are built with symbolic links to reduce file size. For example, Contents/Frameworks/Python.framework/Python is a symlink to Versions/Current/Python rather than a copy of the file.

This is fine for running the app locally, but symbolic links can cause errors when you ZIP and upload the .app for protection. You may see errors like:

FileExistsError: [Errno 17] File exists: 'Versions/Current/Resources'

The fix: After building, use rsync to create a standalone copy that replaces all symlinks with real files:

# Build as usual
pyinstaller --onedir --windowed myapp.py

# Create a portable copy with symlinks resolved
rsync -aL dist/MyApp.app/ dist/MyApp_Standalone.app/

The -L flag tells rsync to follow every symbolic link and copy the actual file it points to. The resulting .app will be larger (because duplicated files are no longer shared via symlinks) but is fully portable and safe to ZIP and upload.

Then upload the standalone copy:

pylocket protect --app <APP_ID> --artifact dist/MyApp_Standalone.app --platform macos-universal2

Quick check

Verify your .app has no symlinks before uploading:

find dist/MyApp_Standalone.app -type l

If this prints nothing, the bundle is clean and ready to upload.

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             # PyInstaller --onefile (Linux)

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-universal2

# Linux
pylocket protect --app <APP_ID> --artifact dist/PyLocket/Linux/myapp --platform linux-x64

Download

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

Output Structure

dist/protected/
├── myapp.exe              # (or MyApp.app / myapp)
├── <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.zip

# Submit for notarization
xcrun notarytool submit MyApp.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 (Linux/macOS), or a .zip of the onedir output
Missing modules at runtime Add --hidden-import flags to your PyInstaller command
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 Verify the --python version matches your build environment.
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