I recently worked on a macOS desktop app using Wails that needed Docker containers under the hood, built for internal company use only. I didn’t want users to be bothered with the whole Docker and container lifecycle, so the solution I came up with was to bundle the required Docker images as .tar files right inside the macOS app package. From there, I used Go’s exec package to spin up and manage the containers programmatically, so the app could quietly take care of the Docker lifecycle.
In general, it works like this:
Step 1: Accessing bundled resources
First, locate the Resources folder within the app bundle:
func getResourcePath() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", err
}
exeDir := filepath.Dir(exePath)
resourcePath := filepath.Join(exeDir, "..", "Resources")
absPath, err := filepath.Abs(resourcePath)
if err != nil {
return "", err
}
return absPath, nil
}
This navigates from the executable location up to the Resources directory. Here’s the app bundle structure:
MyApp.app/
├── Contents/
│ ├── MacOS/
│ │ └── MyApp (executable)
│ ├── Resources/
│ │ └── docker-image.tar
│ └── Info.plist
Step 2: Managing docker image
The LoadImage()
method checks if the image already exists and loads it only when necessary:
func (s *Scanner) LoadImage() error {
cmd := exec.Command(s.dockerPath, "images", "-q", s.ImageName)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to check existing images: %w", err)
}
if len(output) > 0 {
// Image already exists, skip loading
return nil
}
cmd = exec.Command(s.dockerPath, "load", "-i", s.TarPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to load docker image: %w", err)
}
log.Println("Docker image loaded successfully")
return nil
}
Step 3: Container lifecycle management
Starting and stopping containers is straightforward, but it can be improved (e.g. to handle port conflicts)
func (s *Scanner) StartContainer() error {
cmd := exec.Command(s.dockerPath, "run", "-d", "--rm",
"-p", fmt.Sprintf("%d:3000", s.Port),
"--name", "app",
s.ImageName,
)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
return nil
}
func (s *Scanner) StopContainer() error {
cmd := exec.Command(s.dockerPath, "stop", "app")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
return nil
}
The overall process
- Build the Wails app:
wails build -platform darwin/universal -clean
- Bundle the Docker image as a
.tar
file in theResources
folder:cp docker-image.tar build/bin/MyApp.app/Contents/Resources/
- Package everything into a
.dmg
using create-dmg
What works well:
- It reduces support overhead related to Docker setup
- There are no user configuration requirements for containers
The trade-offs:
- Larger bundle size (depending on the bundled Docker image)
- Users still need Docker installed and running