APR 09, 202617 MIN READ

Git Course

Basic git course for individual developers.

Martin Binder

Martin Binder

bndrmrtn@gmail.com

Opinionated Git Workflow Tutorial for a Solo Full-Stack Project

This is a practical, opinionated workflow for a solo developer building and deploying a project with:

  • frontend
  • backend API
  • worker
  • Docker
  • Nginx
  • server deployment

It is designed for this exact situation:

  • you know Git basics
  • you work mostly alone
  • you currently commit straight to main
  • you deploy when something feels ready
  • you want a cleaner history without adding a lot of ceremony

The main goal is simple:

Keep main clean, keep deployments predictable, and make your Git history useful.


1. The core idea

Your current workflow is probably something like this:

  1. work on main
  2. make a few commits whenever needed
  3. deploy when the change looks good

That works, but it causes problems later:

  • main contains messy progress history
  • unfinished work can sit next to production-ready work
  • rollback is harder
  • deploys are harder to trace
  • old commits are not very useful to read

A better workflow is:

  • main is always deployable
  • every task gets its own branch
  • commits should represent logical changes
  • branch history can be cleaned before merging
  • deployment happens only from main
  • production releases are tagged

This is simple enough for a solo dev and clean enough to scale later.


2. The project we are targeting

We will assume a project like this:

myapp/
├── frontend/
│   ├── src/
│   ├── public/
│   ├── Dockerfile
│   └── package.json
├── backend/
│   ├── src/
│   ├── migrations/
│   ├── Dockerfile
│   └── package.json
├── worker/
│   ├── src/
│   ├── jobs/
│   ├── Dockerfile
│   └── package.json
├── nginx/
│   ├── default.conf
│   └── Dockerfile
├── deploy/
│   ├── deploy.sh
│   ├── migrate.sh
│   ├── backup.sh
│   └── rollback.sh
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── README.md
└── .gitignore

Typical responsibilities:

  • frontend/ → SPA or web app
  • backend/ → API, auth, database, business logic
  • worker/ → async jobs, queues, email sending, reports
  • nginx/ → reverse proxy, static serving, caching, routing
  • deploy/ → deployment scripts and server utilities

3. The branch strategy

You do not need GitFlow.

For a solo project, this is enough:

  • main → production-ready
  • feat/... → features
  • fix/... → bug fixes
  • refactor/... → code cleanup without behavior change
  • chore/... → config, tooling, maintenance
  • docs/... → docs changes

Examples:

git checkout -b feat/password-reset
git checkout -b fix/nginx-websocket-proxy
git checkout -b refactor/auth-service
git checkout -b chore/docker-prod-cleanup

This naming helps a lot when scanning branches and commit history.


4. The rule that changes everything

Never commit unfinished work directly to main.

That is the biggest improvement you can make.

Treat main like this:

  • public history
  • deployable history
  • clean history

That means:

  • no half-done work on main
  • no experiments on main
  • no random debugging commits on main
  • no direct deploy from local dirty state

Instead:

  • start from main
  • create a short-lived branch
  • do the work
  • commit in logical pieces
  • clean the branch history
  • merge into main
  • deploy from main

5. The default daily workflow

Use this every time you start a task.

Step 1: update main

git checkout main
git pull origin main

Step 2: create a task branch

git checkout -b feat/invoice-export

Step 3: work in the branch

Make changes in frontend, backend, worker, Docker, or Nginx as needed.

Step 4: commit in logical chunks

Not one giant commit. Not random commits. Logical commits.

Step 5: clean the history before merging

git rebase -i main

Step 6: merge into main

git checkout main
git pull origin main
git merge --ff-only feat/invoice-export
git push origin main

Step 7: deploy from main

Your server should deploy the exact code from main, not from local uncommitted state.


6. What makes a commit good

A good commit should represent one logical change.

Good commit examples:

  • add password reset request endpoint
  • add worker job for password reset emails
  • fix nginx websocket proxy headers
  • refactor auth token verification
  • update docker compose mail settings

Bad commit examples:

  • fix
  • updates
  • wip
  • more changes
  • works now
  • final

A good commit answers this question:

Why does this commit exist?

If you cannot answer that in one sentence, the commit is probably too vague or too mixed.


7. Commit by intent, not by file count

Do not think like this:

  • I changed 10 files, so this must be one commit.

Think like this:

  • These edits together implement one idea.

A logical change can touch many files.

Example: password reset

This might involve:

  • backend route
  • backend validation
  • worker job
  • frontend form
  • environment variables
  • Nginx API routing

That does not mean everything must go into one commit.

A cleaner sequence could be:

  • add password reset request endpoint
  • add worker job for password reset emails
  • add frontend password reset request form
  • add password reset confirmation flow
  • update production mail environment settings

That is much more readable.


8. Use staging properly

This habit improves commit quality a lot:

git add -p

This lets you stage file changes piece by piece.

Why it matters:

  • you can separate cleanup from real logic
  • you can avoid committing debug logs
  • you can split unrelated edits from the same file
  • you can build clean commits from a messy worktree

A very good loop is:

git status
git diff
git add -p
git commit -m "add invoice export endpoint"

If you only adopt one new Git habit, adopt this one.


9. Commit message style

Use short imperative messages.

Good style:

  • add invoice export endpoint
  • fix nginx API upstream timeout
  • refactor worker retry scheduling
  • remove unused frontend auth helper
  • update docker compose production env vars

Avoid:

  • added invoice export endpoint
  • fixed bug
  • changes
  • final version
  • stuff

A simple rule:

verb + object + optional context

Examples:

  • add backend healthcheck endpoint
  • fix duplicate email sends in worker
  • refactor frontend API client error handling
  • update nginx static asset cache headers

10. When to write a commit body

Use a commit body when the subject alone is not enough.

Example:

fix duplicate email sends in worker retry loop

The worker retried after timeout failures, but some provider requests
had already been accepted remotely. Add idempotency tracking so the
same email job is not sent twice.

A commit body is useful for:

  • explaining non-obvious bugs
  • recording tradeoffs
  • noting migration details
  • documenting temporary workarounds
  • describing production-sensitive changes

11. Keep different types of changes separate

Try not to mix these in one commit:

  • feature logic
  • refactor
  • formatting
  • dependency updates
  • Docker changes
  • Nginx config changes
  • deploy script changes

Bad mixed commit:

  • adds new backend endpoint
  • updates frontend styling
  • changes worker retry logic
  • edits Nginx proxy config
  • renames Docker image

Better separated version:

  • add backend endpoint for password reset request
  • add frontend password reset request form
  • fix worker retry backoff handling
  • update nginx route for reset endpoints
  • rename backend Docker image

This makes history easier to read, revert, and debug.


12. Opinionated workflow for a full-stack feature

Let’s walk through a realistic example.

Suppose you want to add password reset.

This affects:

  • frontend UI
  • backend token logic
  • worker email job
  • Docker environment
  • Nginx routing

Step 1: create the branch

git checkout main
git pull origin main
git checkout -b feat/password-reset

Step 2: implement the backend base

You add:

  • endpoint to request reset
  • token creation logic
  • database persistence if needed

Good commit:

git commit -m "add password reset request endpoint"

Step 3: implement the worker job

You add:

  • queue payload
  • email sending job
  • retry handling

Good commit:

git commit -m "add worker job for password reset emails"

Step 4: implement the frontend request form

You add:

  • forgot password page
  • API call
  • success state

Good commit:

git commit -m "add frontend password reset request form"

Step 6: add production config only if needed

If you need mail provider settings or queue settings:

git commit -m "update production mail configuration"

Step 7: update Nginx only if needed

If routing or upload handling changes:

git commit -m "update nginx API route for password reset endpoints"

That history is clean and tells a story.


13. The Docker side of the workflow

Docker helps keep local development and deployment consistent.

A typical local docker-compose.yml could look like this:

services:
  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    depends_on:
      - backend

  backend:
    build: ./backend
    ports:
      - "4000:4000"
    depends_on:
      - db
      - redis

  worker:
    build: ./worker
    depends_on:
      - backend
      - redis

  nginx:
    build: ./nginx
    ports:
      - "80:80"
    depends_on:
      - frontend
      - backend

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret

  redis:
    image: redis:7

Docker commit advice

Keep Docker commits focused when possible.

Good examples:

  • add redis service for worker queue
  • update backend Dockerfile for production install
  • add worker service to production compose
  • mount nginx config as read-only volume

Do not hide Docker changes inside random feature commits unless they are tightly coupled.


14. The Nginx side of the workflow

Nginx typically handles:

  • frontend route serving
  • SPA fallback
  • backend API proxying
  • websocket headers
  • upload size limits
  • caching headers
  • TLS termination

Example nginx/default.conf:

server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://frontend:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /api/ {
        proxy_pass http://backend:4000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Nginx commit advice

Good Nginx-focused commits:

  • add nginx API upstream for backend
  • enable SPA fallback for frontend routes
  • fix nginx websocket proxy headers
  • increase nginx upload limit for media endpoint
  • add cache headers for static assets

Try not to bury Nginx config edits in unrelated commits.


15. The worker side of the workflow

The worker is often where things get messy because background processing is less visible.

Typical worker jobs:

  • send emails
  • process uploads
  • generate reports
  • handle retries
  • consume queues
  • cleanup expired records
  • run scheduled tasks

Worker changes often deserve their own commits because they are operationally important.

Good worker commits:

  • add worker job for invoice PDF generation
  • fix worker retry delay for failed webhooks
  • refactor queue consumer ack handling
  • add dead-letter handling for failed jobs

This makes operational bugs easier to trace later.


16. Suggested repository rules

These rules are a very good baseline for a solo project:

  • main must stay deployable
  • every task gets a branch
  • commit by logical change
  • use git add -p often
  • do not merge noisy history into main
  • deploy only from main
  • tag production releases
  • keep deploy changes readable
  • do not commit secrets
  • prefer fewer meaningful commits over many vague ones

17. Interactive rebase: your cleanup tool

Before merging, clean your branch:

git rebase -i main

This is one of the most useful Git tools for a solo dev.

It lets you:

  • rename bad commit messages
  • reorder commits
  • squash noisy commits
  • merge typo or debug cleanup into the right commit
  • remove accidental junk commits

Example noisy history:

pick a1b2c3 add reset endpoint
pick b2c3d4 fix typo
pick c3d4e5 remove debug log
pick d4e5f6 add worker email job
pick e5f6g7 wip frontend form
pick f6g7h8 finish frontend form

Better cleaned history:

pick a1b2c3 add reset endpoint
fixup b2c3d4 fix typo
fixup c3d4e5 remove debug log
pick d4e5f6 add worker email job
pick f6g7h8 add frontend password reset request form
drop e5f6g7 wip frontend form

The final history is much better.


18. Should you squash everything?

Not always.

Squash when:

  • the branch has lots of noisy progress commits
  • the whole change is very small
  • intermediate commits are not useful

Keep multiple commits when:

  • each commit represents a meaningful step
  • each commit could help debugging later
  • separate rollback might be useful
  • the history tells a clear story

Good history is not the shortest history.

Good history is the most useful history.


19. A recommended merge policy

For a solo repo, a clean rule is:

Merge to main only when:

  • the feature is working
  • the key flows were tested
  • the branch history is cleaned
  • main remains deployable

Use:

git checkout main
git pull origin main
git merge --ff-only feat/password-reset
git push origin main

Why --ff-only is nice:

  • it avoids unnecessary merge commits
  • it keeps history linear
  • it makes main easier to read

20. Deployment rule

Deploy only from main.

Do not deploy from:

  • a feature branch
  • your local modified working directory
  • an uncommitted state
  • a random server hotfix that never lands in Git

A better flow is:

  1. finish the branch
  2. clean branch history
  3. merge to main
  4. push main
  5. deploy the exact main commit
  6. tag the release if appropriate

This creates a strong connection between:

  • Git history
  • deployed code
  • rollback points

21. Suggested production flow

On your local machine

git checkout main
git pull origin main
git merge --ff-only feat/password-reset
git push origin main
git tag -a v1.5.0 -m "Release v1.5.0"
git push origin v1.5.0

On the server

git pull origin main
docker compose -f docker-compose.prod.yml up -d --build

If needed, also run migrations:

./deploy/migrate.sh

This is much safer than deploying from some unknown local state.


22. Opinionated production file layout

A more complete production-aware layout could look like this:

myapp/
├── frontend/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
├── backend/
│   ├── src/
│   ├── migrations/
│   ├── Dockerfile
│   └── package.json
├── worker/
│   ├── src/
│   ├── jobs/
│   ├── Dockerfile
│   └── package.json
├── nginx/
│   ├── default.conf
│   ├── ssl/
│   └── Dockerfile
├── deploy/
│   ├── deploy.sh
│   ├── migrate.sh
│   ├── backup.sh
│   └── rollback.sh
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── README.md
└── .gitignore

Useful rule:

  • app logic in service directories
  • reverse proxy logic in nginx/
  • deployment logic in deploy/
  • local vs production compose files split clearly

23. Sample production compose file

Here is a simple example:

services:
  frontend:
    build: ./frontend
    restart: unless-stopped

  backend:
    build: ./backend
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      - db
      - redis

  worker:
    build: ./worker
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      - redis
      - db

  nginx:
    build: ./nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - frontend
      - backend

  db:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/data
    env_file:
      - .env

  redis:
    image: redis:7
    restart: unless-stopped

volumes:
  postgres_data:

Good commit messages around this file:

  • add worker service to production compose
  • add persistent Postgres volume
  • load backend environment from .env
  • expose nginx on 80 and 443

24. Sample deploy script

Example deploy/deploy.sh:

#!/usr/bin/env bash

# Exit on first error
set -e

# Pull the latest main branch
git pull origin main

# Rebuild and restart production services
docker compose -f docker-compose.prod.yml up -d --build

# Run database migrations
./deploy/migrate.sh

Good deploy script commit messages:

  • add deploy script for production compose rebuild
  • run migrations as part of deploy script
  • add backup step before deploy

Keep deploy script changes separate from feature commits if possible.


25. Sample migration script

Example deploy/migrate.sh:

#!/usr/bin/env bash

# Exit on first error
set -e

# Run backend migrations inside the backend container
docker compose -f docker-compose.prod.yml exec backend npm run migrate

This kind of script makes deploy steps repeatable.


26. Sample rollback idea

A simple rollback script could redeploy the previous tagged version or a known commit.

Example concept:

#!/usr/bin/env bash

# Exit on first error
set -e

# Checkout a known good tag before rebuild
git checkout "$1"

# Restart services from that version
docker compose -f docker-compose.prod.yml up -d --build

You would likely improve this for real production use, but even a simple rollback approach is much better than guessing.


27. Release tagging

Tag production releases.

Example:

git tag -a v1.6.0 -m "Release v1.6.0"
git push origin v1.6.0

Why this matters:

  • you know exactly what was deployed
  • rollback is easier
  • changelog writing is easier
  • production incidents are easier to trace

A simple versioning scheme:

  • v1.0.0 → stable starting release
  • v1.1.0 → new feature release
  • v1.1.1 → bugfix release

28. Example full feature workflow

Let’s do a realistic example: invoice export.

This touches:

  • backend endpoint
  • worker report generation
  • frontend export button
  • Docker env config
  • maybe Nginx route rules

Start branch

git checkout main
git pull origin main
git checkout -b feat/invoice-export

Possible commit sequence

git commit -m "add invoice export request endpoint"
git commit -m "add worker job for invoice export generation"
git commit -m "add frontend invoice export action"
git commit -m "update production queue environment settings"

Clean history

git rebase -i main

Merge

git checkout main
git pull origin main
git merge --ff-only feat/invoice-export
git push origin main

Tag if release-worthy

git tag -a v1.6.0 -m "Release v1.6.0"
git push origin v1.6.0

Deploy

./deploy/deploy.sh

That is a strong workflow with very little overhead.


29. Example bugfix workflow

Suppose WebSocket notifications break behind Nginx.

Start fix branch

git checkout main
git pull origin main
git checkout -b fix/websocket-proxy

Make the fix

Maybe you update:

  • Nginx websocket headers
  • backend trusted proxy handling
  • production compose networking

Good commit sequence:

git commit -m "fix nginx websocket proxy headers"
git commit -m "update backend trusted proxy configuration"

Clean and merge

git rebase -i main
git checkout main
git merge --ff-only fix/websocket-proxy
git push origin main

Deploy and tag if needed

git tag -a v1.6.1 -m "Release v1.6.1"
git push origin v1.6.1

30. Example refactor workflow

Suppose auth logic in the backend is messy.

This is not a new feature. It is a refactor.

Start branch

git checkout main
git pull origin main
git checkout -b refactor/auth-module

Good commit sequence:

  • split auth service into token and session modules
  • rename auth middleware for consistency
  • remove unused auth helper functions
  • add tests for refactored auth service

This makes it clear that behavior should stay the same while structure improves.


31. What to stop doing

Try to eliminate these habits:

  • committing directly to main
  • using wip in permanent history
  • bundling unrelated changes into one commit
  • mixing formatting with logic changes
  • deploying from local dirty state
  • changing production servers manually without Git history
  • making Docker or Nginx edits without clear commit messages

These habits make history noisy and deployment risky.


32. Commit checklist

Before each commit, ask:

  • is this one logical change?
  • am I mixing unrelated systems?
  • did I remove debug code?
  • does the message say what changed clearly?
  • would future-me understand this commit quickly?

If not, split or rename the commit.


33. Pre-merge checklist

Before merging a branch into main:

  • app builds
  • key flows work
  • no accidental secrets are staged
  • commit history is cleaned
  • commit messages are readable
  • main will stay deployable

34. Deploy checklist

Before production deploy:

  • main contains the exact code you want
  • migrations were reviewed
  • env vars are set correctly
  • compose changes were checked
  • Nginx changes were checked
  • worker jobs were checked for breaking behavior
  • release was tagged if appropriate
  • backup plan exists for risky deploys

35. Useful commands cheat sheet

Start a new task

git checkout main
git pull origin main
git checkout -b feat/my-task

Review changes

git status
git diff

Stage carefully

git add -p

Commit

git commit -m "add backend healthcheck endpoint"

Fix the last commit message

git commit --amend

Clean branch history

git rebase -i main

Merge into main

git checkout main
git pull origin main
git merge --ff-only feat/my-task
git push origin main

Tag release

git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0

36. A sample ideal history

A nice main history might look like this:

  • add backend healthcheck endpoint
  • add nginx route for healthcheck
  • add worker job for invoice PDF generation
  • fix frontend login redirect after token refresh
  • update production compose env loading
  • tag release v1.6.0

That history is readable, useful, and easy to navigate later.


37. The workflow to adopt today

Use this as your default standard:

  • I do all work on a branch.
  • I commit by logical change, not by mood.
  • I use git add -p when needed.
  • I clean history before merging.
  • main is always deployable.
  • I deploy only from main.
  • I tag production releases.

That is a strong, sustainable workflow for a solo full-stack project.