Sync Local Development Environments With Make and Git
Published October 9, 2026 · Editorial policy

You switch between your MacBook Pro at your standing desk and a Linux desktop in your home office, but your local development environment is constantly out of sync. Every time you switch machines, you spend the first twenty minutes debugging missing environment variables, mismatched Node versions, or Docker volumes pointing to non-existent paths. You need a frictionless way to replicate your exact working state across devices without relying on brittle cloud configuration apps or manual checklists.
Here is how to use Git to version-control your tooling configuration and GNU Make to execute identical environment setup commands with a single keystroke.
The Strategy: Dotfiles, Makefiles, and Symlinks
To sync your environment across machines, you must separate your application source code from your local development infrastructure. Your tooling configuration—such as .env.example templates, Docker Compose overrides, global git hooks, and local bin scripts—should live in a dedicated repository or inside your project repository under a standardized automation layer.
GNU Make acts as your universal cross-platform execution engine. Because Make evaluates file timestamps and executes shell commands predictably across macOS and Linux, it abstracts away the subtle differences between your operating systems.
Your workflow will follow three rules:
- Version-control all environment setup logic in a
Makefile. - Store machine-agnostic configuration templates in Git.
- Use a single
make setupcommand to provision dependencies, build local databases, and symlink local configs.
Step 1: Write the Universal Makefile
Create a file named Makefile in the root of your project directory. This file will serve as the single source of truth for setting up your environment, whether you are pulling the repo for the first time on a fresh laptop or updating dependencies on your primary workstation.
SHELL := /bin/bash
.DEFAULT_GOAL := help
# Detect operating system
OS := $(shell uname)
.PHONY: help setup clean update
help: ## Show this help message
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " %-15s \033[32m%s\033[0m\n", $$1, $$2 }' $(MAKEFILE_LIST)
setup: ## Run full environment provisioning
@echo "==> Setting up development environment on $(OS)..."
@$(MAKE) check-deps
@$(MAKE) .env
@$(MAKE) docker-build
@$(MAKE) db-migrate
@echo "==> Setup complete! Run 'make dev' to start."
check-deps: ## Verify required CLI tools are installed
@which docker > /dev/null || (echo "Error: docker is not installed." && exit 1)
@which node > /dev/null || (echo "Error: node is not installed." && exit 1)
@which pnpm > /dev/null || (echo "Error: pnpm is not installed. Run 'npm i -g pnpm'." && exit 1)
@echo "==> All required dependencies found."
.env: ## Generate local environment file from template if missing
@if [ ! -f .env ]; then \
cp .env.example .env; \
echo "==> Created .env from .env.example. Please update your credentials."; \
else \
echo "==> .env already exists, skipping creation."; \
fi
docker-build: ## Build local Docker development containers
@echo "==> Pulling and building Docker images..."
@docker compose pull
@docker compose build
db-migrate: ## Run database migrations inside the local container
@echo "==> Running database migrations..."
@docker compose exec -T app pnpm prisma migrate dev
clean: ## Destroy local containers and volumes (Warning: destructive)
@echo "==> Cleaning up local environment..."
@docker compose down -v --remove-orphans
@rm -f .env
@echo "==> Environment wiped clean."
Save this file and commit it to Git. Every developer—and every machine you own—now has access to the exact same provisioning pipeline.
Step 2: Handle Machine-Specific Overrides Safely
Not all machines are identical. Your MacBook might run Docker Desktop with default resource limits, while your Linux desktop allocates 32GB of RAM and custom CPU pinning to local containers. Hardcoding these paths or settings into your main configuration files will break your sync pipeline.
To handle machine-specific variables without polluting Git, use Docker Compose's native override mechanism alongside your Makefile.
Structuring Your Docker Compose Files
Organize your project root with these three files:
docker-compose.yml: The shared, production-like baseline configuration committed to Git.docker-compose.override.yml: Local customizations automatically loaded by Docker Compose, listed in your.gitignore.docker-compose.override.example.yml: A template committed to Git showing available local tuning options.
Create docker-compose.override.example.yml:
version: '3.8'
services:
app:
environment:
- NODE_ENV=development
- LOG_LEVEL=debug
# Uncomment to mount local source code differently on Linux vs macOS if needed
# volumes:
# - .:/app
Update your Makefile to automatically provision this override file on a new machine if it does not exist:
docker-compose.override.yml:
@if [ ! -f docker-compose.override.yml ]; then \
cp docker-compose.override.example.yml docker-compose.override.yml; \
echo "==> Created docker-compose.override.yml from template."; \
fi
When you switch to a new machine, running make setup generates your .env and your docker-compose.override.yml automatically. You only need to edit the values inside those files if your hardware requires specific tuning.
Step 3: Git Ignore Hygiene and Global Tool Syncing
To keep your synchronization clean, your Git repository must never track local state files, log outputs, or machine-specific binaries.
The Essential .gitignore
Add these entries to your project's .gitignore file to ensure local environment artifacts never leak into your remote repository:
# Local environment configurations
.env
.env.local
docker-compose.override.yml
# OS generated files
.DS_Store
Thumbs.db
# Local dependency directories
node_modules/
dist/
build/
.pnpm-debug.log
# Local IDE settings
.vscode/
.idea/
*.swp
Syncing Global Git Hooks via Make
Often, maintaining a consistent environment means ensuring pre-commit linters and formatters run identically on all your machines. Instead of installing Husky or global hooks manually, manage them via your Makefile.
Add this target to your Makefile:
init-hooks: ## Configure local git hooks path
@git config core.hooksPath .githooks
@chmod +x .githooks/*
@echo "==> Git hooks configured from .githooks/ directory."
Create a directory named .githooks in your project root and add a pre-commit script named pre-commit:
#!/usr/bin/env bash
set -e
echo "==> Running pre-commit checks..."
make lint
make test-unit
Make it executable on your current machine:
chmod +x .githooks/pre-commit
When you pull your repository down on your secondary machine, running make init-hooks ensures that the exact same pre-commit validation rules are enforced locally without requiring global npm package installations.
Day-to-Day Workflow Across Machines
Once your Makefile and Git configuration are established, switching machines takes less than two minutes.
On your laptop, you finish a feature, commit your changes, and push to GitHub:
git add .
git commit -m "feat: add user authentication endpoint"
git push origin main
Walk over to your desktop machine, open your terminal, and pull the changes:
git pull origin main
Now, provision or update your local environment with a single command:
make setup
Your terminal outputs the execution steps, verifying dependencies, generating missing local config files, building your containers, and running migrations:
==> Setting up development environment on Linux...
==> All required dependencies found.
==> Created .env from .env.example. Please update your credentials.
==> Created docker-compose.override.yml from template.
==> Pulling and building Docker images...
[+] Building 1.2s (9/9) FINISHED
==> Running database migrations...
==> Setup complete! Run 'make dev' to start.
If you modify a dependency or add a new environment variable in env.example on your laptop, your desktop will automatically flag the need for an update the next time you run make setup.
Troubleshooting Common Sync Failures
1. Make complains about missing tabs
GNU Make strictly requires literal tab characters for command indentation. If you copy-paste Makefile snippets from a browser and your editor converts tabs to spaces, Make will fail with:
Makefile:3: *** missing separator. Stop.
The Fix: Open your editor settings for Makefiles and ensure expandtab is disabled, or use sed to replace spaces with tabs:
sed -i 's/^ /\t/' Makefile
2. File permission mismatches between macOS and Linux Docker volumes
When mounting local volumes into Docker containers, Linux binds UID/GID directly, while macOS abstracts user permissions through a virtual machine layer. If your container throws permission denied errors on Linux after working fine on macOS, your user IDs diverge.
The Fix: Add a user-mapping variable to your docker-compose.override.yml on your Linux machine:
services:
app:
user: "${UID}:${GID}"
And export those variables in your local shell profile (~/.bashrc or ~/.zshrc):
export UID=$(id -u)
export GID=$(id -g)
3. Outdated lockfiles causing build drift
If make setup succeeds on your laptop but fails with dependency resolution errors on your desktop, your local package manager versions might be out of sync.
The Fix: Add a version constraint check to your Makefile's check-deps target to force matching CLI tool versions across machines:
@pnpm --version | grep -q "^8\." || (echo "Error: pnpm v8 required." && exit 1)
Alternatives and Future Prevention
While Make and Git provide a lightweight, dependency-free synchronization method, alternative approaches exist depending on your team's infrastructure:
- Devcontainers (VS Code / GitHub Codespaces): Encapsulates your entire OS, tooling, and editor extensions inside a Docker container. This completely eliminates machine-to-machine drift, but requires heavy resource allocation and ties your workflow to specific IDE support.
- Nix / Nixpacks: Provides deterministic, reproducible package management across macOS and Linux. While immensely powerful, Nix has a steep learning curve and requires maintaining custom expressions.
For most developers, combining Git for configuration versioning and Make for task automation hits the sweet spot: it requires zero external service dependencies, runs on any Unix-like OS out of the box, and keeps your local development environments identical across all your machines.


