Automation

Sync Local Development Environments With Make and Git

Tech Setup6 min read
TS

Tech Setup

Published October 9, 2026 · Editorial policy

Sync Local Development Environments With Make and Git

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.

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:

  1. Version-control all environment setup logic in a Makefile.
  2. Store machine-agnostic configuration templates in Git.
  3. Use a single make setup command 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.