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
mainclean, keep deployments predictable, and make your Git history useful.
1. The core idea
Your current workflow is probably something like this:
- work on
main - make a few commits whenever needed
- deploy when the change looks good
That works, but it causes problems later:
maincontains 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:
mainis 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 appbackend/→ API, auth, database, business logicworker/→ async jobs, queues, email sending, reportsnginx/→ reverse proxy, static serving, caching, routingdeploy/→ deployment scripts and server utilities
3. The branch strategy
You do not need GitFlow.
For a solo project, this is enough:
main→ production-readyfeat/...→ featuresfix/...→ bug fixesrefactor/...→ code cleanup without behavior changechore/...→ config, tooling, maintenancedocs/...→ 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 endpointadd worker job for password reset emailsfix nginx websocket proxy headersrefactor auth token verificationupdate docker compose mail settings
Bad commit examples:
fixupdateswipmore changesworks nowfinal
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 endpointadd worker job for password reset emailsadd frontend password reset request formadd password reset confirmation flowupdate 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 endpointfix nginx API upstream timeoutrefactor worker retry schedulingremove unused frontend auth helperupdate docker compose production env vars
Avoid:
added invoice export endpointfixed bugchangesfinal versionstuff
A simple rule:
verb + object + optional context
Examples:
add backend healthcheck endpointfix duplicate email sends in workerrefactor frontend API client error handlingupdate 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 requestadd frontend password reset request formfix worker retry backoff handlingupdate nginx route for reset endpointsrename 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 queueupdate backend Dockerfile for production installadd worker service to production composemount 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 backendenable SPA fallback for frontend routesfix nginx websocket proxy headersincrease nginx upload limit for media endpointadd 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 generationfix worker retry delay for failed webhooksrefactor queue consumer ack handlingadd 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:
mainmust stay deployable- every task gets a branch
- commit by logical change
- use
git add -poften - 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
mainremains 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
maineasier 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:
- finish the branch
- clean branch history
- merge to
main - push
main - deploy the exact
maincommit - 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 composeadd persistent Postgres volumeload backend environment from .envexpose 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 rebuildrun migrations as part of deploy scriptadd 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 releasev1.1.0→ new feature releasev1.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 modulesrename auth middleware for consistencyremove unused auth helper functionsadd 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
wipin 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
mainwill stay deployable
34. Deploy checklist
Before production deploy:
maincontains 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 endpointadd nginx route for healthcheckadd worker job for invoice PDF generationfix frontend login redirect after token refreshupdate production compose env loadingtag 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 -pwhen needed. - I clean history before merging.
mainis always deployable.- I deploy only from
main. - I tag production releases.
That is a strong, sustainable workflow for a solo full-stack project.
