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.

Do not flatten the bundle with rsync -aL

Older guidance suggested rsync -aL dist/MyApp.app/ dist/MyApp_Standalone.app/ to get past a FileExistsError: Versions/Current/Resources seen with some zip tools. Do not use it. Use the CLI or ditto above, which keep the symlinks. -L replaces every symlink with a real copy of what it points to, so the upload roughly doubles and the bundle carries second real copies of files PyInstaller laid out as links, binaries included (for example the Python framework binary and lib*.dylib files).

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 does not sign your app: until you sign and notarize it with your own Developer ID, macOS blocks it when a customer downloads it in a browser, and the PyLocket Installer and Hub refuse to install it. Code Signing explains what PyLocket does to the signature.

After downloading the protected .app, re-sign it inside-out with your Apple Developer ID certificate under the hardened runtime: the binaries under Contents/Resources/ first (codesign --deep alone does not reach them), then the bundle. Always sign the bundle with the com.apple.security.cs.disable-library-validation entitlement so it loads PyLocket's runtime library, which Lamaute Labs signs. Code Signing has the full script, including entitlements.plist:

# 1. Every Mach-O under Contents/Resources
find MyApp.app/Contents/Resources -type f -print0 |
  while IFS= read -r -d '' f; do
    if file -b "$f" | grep -q 'Mach-O'; then
      codesign --force --options runtime --timestamp \
        --sign "Developer ID Application: Your Name (TEAMID)" "$f"
    fi
  done

# 2. The bundle
codesign --deep --force --options runtime --timestamp \
  --entitlements entitlements.plist \
  --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/
# then sign the .app inside-out, as above

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. Signing your builds with your own certificate builds antivirus reputation for your future builds; a rejected build cannot be replaced, so upload again once the false positive is cleared.
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" or "cannot be verified" The .app is not signed with your Developer ID and notarized, so Gatekeeper blocks the download. Sign and notarize it (Code Signing (macOS)) and upload the signed copy. To test an unsigned build on your own Mac, open System Settings → Privacy & Security and click Open Anyway (macOS 15 and later no longer offer right-click → 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/...' This error usually comes from the symbolic links in a PyInstaller 6+ .app. Re-archive it with a tool that keeps them: upload the .app with pylocket protect, which archives it with its symlinks, or archive it with ditto -c -k --keepParent YourApp.app YourApp.app.zip (not zip -r or Finder's Compress) and upload the .zip. Do not flatten the links with rsync -aL. See PyInstaller 6+ symlinks above.

See Also