A Docker primer
What Docker is, how Grit uses it, how to run Grit without it, and the commands you actually need.
Before we boot the dev servers, let's deal with the elephant in the room: Docker. Most people get to the docker compose up -d command in the next lesson and stall because nobody actually taught them what Docker is. This lesson teaches it from first principles. By the end you'll know what Docker is, why it exists, how Grit uses it, how to skip it entirely if you want to β and why you should learn it anyway.
The problem Docker solves
Software has dependencies. Your app needs Postgres v16, Redis v7, a specific version of Node, a specific Go toolchain. On your laptop everything works. You hand the project to a teammate β their Postgres is v14 and migrations fail. You deploy to a server β its Redis is missing a feature. You spend a week chasing "works-on-my-machine" bugs.
Docker fixes this by bundling each piece of software with its exact environment into a container β a lightweight, self-contained box. Postgres v16 in a container behaves identically on your Mac, your teammate's Windows machine, and the production Linux server. The whole "but it worked on my machine" era largely ends with this idea.
The three concepts you need today
Images, containers, volumes
That's the whole vocabulary for the next 6 months.
Installing Docker
- Mac / Windows: Install Docker Desktop. It bundles the Docker engine + a GUI + Compose. After install, open the Docker Desktop app β it stays running in your menu / system tray.
- Linux: Use Docker Engine (apt / yum / dnf). No GUI, just the daemon. Add yourself to the
dockergroup so you don't needsudofor every command:sudo usermod -aG docker $USERthen log out and back in.
Verify the install:
docker --version# Docker version 27.x.xdocker compose version# Docker Compose version v2.x.xdocker run --rm hello-world# pulls a tiny test image and runs it β confirms the daemon works
How Grit uses Docker
Grit's docker-compose.yml describes 4 services your app depends on locally:
- Postgres on port 5432 β the database.
- Redis on host port 6380 (container 6379) β cache + job queue.
- MinIO on host ports 9002 (S3 API) + 9003 (web console) β S3-compatible file storage.
- Mailhog on ports 1025 (SMTP) + 8025 (web UI) β fake mail server so you can test emails without sending real ones.
docker compose up -d boots all four in the background. docker compose down stops them. You never had to install Postgres or Redis on your machine β they live in containers and your Go API connects to them via localhost:5434 + localhost:6380. When you're done for the day, run down and your laptop is back to normal.
"127.0.0.1:5434:5432" β host port left, container port right.0.0.0.0 (every interface). On a coffee-shop wifi that exposes Postgres with grit:grit credentials to the whole room. We pinned it to localhost specifically to prevent that.Docker commands you'll actually type
# Start everything in docker-compose.yml in the backgrounddocker compose up -d# Stop everything (preserves data β volumes stay)docker compose down# Stop AND wipe data (use this when you want a fresh DB)docker compose down -v# Stop EVERY running container on your machine β even ones from other# projects you forgot were running. Useful when port 5434 / 6380 / 9002 is# already taken and you can't remember by what.docker stop $(docker ps -q)# See what's runningdocker psdocker compose ps# Live logs from one service (Ctrl+C to exit; data keeps flowing)docker compose logs -f postgres# Open a shell INSIDE a running container (great for debugging)docker compose exec postgres bashdocker compose exec postgres psql -U grit -d my-first-grit# Restart one service after editing its configdocker compose restart redis# Pull newer image versions (e.g., postgres 16.2 β 16.3)docker compose pull# See disk usage β containers + volumes + imagesdocker system df# Reclaim disk from stopped containers + dangling images (safe)docker system prune# Nuclear: also delete unused volumes (DESTROYS DATA β confirm twice)docker system prune --volumes
These thirteen cover 99% of what you'll do. Bookmark this lesson β when in doubt, scroll up.
The errors you WILL hit, and what they mean
1. Cannot connect to the Docker daemon
Docker Desktop isn't running (Mac/Windows) or the daemon isn't started (Linux: sudo systemctl start docker). Open Docker Desktop or start the service.
2. port is already allocated
Something else on your machine already owns the host port (5434, 6380, 9002, or whatever). Probably a leftover container from another project β or in the Postgres case, a native install you forgot about. Either stop it or change the port in docker-compose.yml:
# Was: "127.0.0.1:5432:5432"# Now: "127.0.0.1:5434:5432"# host port ββ ββ container port (stays 5432)# Then update POSTGRES_PORT=5434 in .env so the Go API connects to the# right host port.
Grit 3.26.2+ scaffolds already default the host port to 5434 specifically to dodge this collision class.
2a. Windows: An attempt was made to access a socket in a way forbidden by its access permissions
Same family as "port is already allocated", but with a twist that confuses everyone the first time. You have no containers, no Postgres service, nothing listening on 5432 β and Docker still refuses to bind it. This is Windows reserving port ranges for Hyper-V Virtual Switch / WinNAT and refusing non-Administrator processes the right to bind inside them.
Confirm with PowerShell:
netsh int ipv4 show excludedportrange protocol=tcp# If you see something like:# Start Port End Port# ---------- --------# ...# 5360 5459# then 5432 is inside that reserved range and Docker can't have it.
Three ways out, in order of how aggressive you want to be:
- Cheapest fix: change the host port in
docker-compose.ymlto one outside the reserved range (e.g.,5434as Grit's defaults already do), and setPOSTGRES_PORT=5434in.env. - Reset the WinNAT reservations (Administrator PowerShell). This releases any ranges WinNAT grabbed at boot β useful if you really want to keep 5432:net stop winnatnet start winnat# Then re-run docker compose up -d
- Shift the dynamic port range so the OS stops handing 5432 out (also Administrator):netsh int ipv4 set dynamic tcp start=49152 num=16384
The first option is what we recommend for daily use β there's no operational reason to insist on host port 5432 when nothing connects to it from outside your machine.
3. no space left on device
Docker disk usage piles up over months. Run docker system df to see usage; docker system prune clears safe stuff (stopped containers, dangling images). For aggressive cleanup including unused volumes: docker system prune --volumes β but verify you don't need that data first.
4. Container exits immediately (status: Exited (1))
Check the logs: docker compose logs <service>. The last 20 lines usually tell you exactly what went wrong (missing env var, password mismatch, port collision).
5. permission denied on Linux
Either run with sudo (annoying) or add yourself to the docker group: sudo usermod -aG docker $USER then log out + back in.
6. healthcheck failed
Postgres / Redis are still starting up. Wait 5-10 seconds and the healthcheck should pass. If it stays red after a minute, check the logs β usually an env var typo.
7. password authentication failed for user "grit" (SQLSTATE 28P01)
Grit 3.26+ projects use a single source of truth for Postgres credentials β the POSTGRES_* block in .env. docker-compose.yml reads it via ${POSTGRES_USER} / ${POSTGRES_PASSWORD} /${POSTGRES_DB} substitution; the Go API builds DATABASE_URL from the same parts. So this error shouldn't happen on a fresh scaffold.
You can still hit it if you:
- Manually set
DATABASE_URLin.envAND let it disagree withPOSTGRES_*. Solution: set one OR the other, not both.DATABASE_URLwins when set β it's the "external Postgres" escape hatch (Neon, Supabase, RDS). - Change
POSTGRES_PASSWORDin.envafter Postgres has already initialised its data volume.
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB from its environment only on first-time volume init. Edit .env after the container has already run once and the change is silently ignored β the password baked into the volume on first boot wins. To pick up new credentials you have to wipe the volume:docker compose down -v # the -v drops the postgres-data volumedocker compose up -d # Postgres re-initialises with the new password from .envgrit migrate # recreate the schemagrit seed # re-seed dev data
grit seed and you're back where you were.Running Grit WITHOUT Docker
You don't actually need Docker to use Grit. You need:
- A Postgres database (somewhere β local install, cloud, container)
- A Redis instance (same)
- S3-compatible storage (optional β only if you handle uploads)
- An SMTP server or a transactional email service (only if you send mail)
Grit's scaffold ships with .env.cloud.example for exactly this case. Copy it over .env:
cp .env.cloud.example .env# Then fill in the connection strings
The cloud picks I recommend for a free, no-Docker setup:
| Service | Provider | Free tier? | env var |
|---|---|---|---|
| Postgres | Neon | Yes β 0.5 GB | DATABASE_URL |
| Redis | Upstash | Yes β 10k commands/day | REDIS_URL |
| Object storage | Cloudflare R2 | Yes β 10 GB, no egress fees | STORAGE_* |
| Resend | Yes β 3k/month, 1 domain | RESEND_API_KEY |
Sign up for each (5 minutes), paste the connection string into your .env, and your Grit project runs against managed services. No Docker, no localhost dependencies. Many production deploys actually look like this anyway β your Go binary on a small VPS, talking to managed Postgres + Redis. Docker is for local development convenience, not a hard requirement.
So⦠should I learn Docker?
Yes. Even if you start with the cloud-only setup, learn Docker. Three reasons:
- Onboarding speed. A new teammate clones the repo, runs
docker compose up -d, and is productive in 60 seconds. With managed services they have to sign up for 4 things and fill in.envvalues from a shared password manager. Big difference at scale. - Reproducibility. Same Postgres version, same Redis version, same MinIO bucket policy, every dev. Bugs reproduce; fixes stay fixed.
- Production deployment. Most modern deploy targets (Dokploy, Coolify, Fly, Railway, Render, Kubernetes, ECS) speak Docker. The Dockerfile you ship today is your production artifact tomorrow. The patterns you learn here scale to every job you'll ever have.
The first hour is the hardest. The second hour you're reading logs. After day two you wonder how you ever shipped software without it.
Quick check
Try it
Get comfortable with the daily Docker loop:
- Make sure Docker Desktop (or your Linux daemon) is running. Run
docker run --rm hello-worldβ confirm it works. - In your
my-first-gritfolder, rundocker compose up -d. Thendocker compose psβ confirm 4 services arehealthyorrunning. - Open Mailhog at
http://localhost:8025β confirm the inbox loads (empty is fine). - Open MinIO at
http://localhost:9003β loginminioadmin / minioadmin, confirm you see the console. - Run
docker compose logs -f postgres. Wait 5 seconds, then Ctrl+C. You should see startup logs ending in "database system is ready to accept connections". - Run
docker compose down(no-v!) and confirm the containers stop. Rundocker compose psβ list should be empty.
Paste a screenshot of step 2's output into notes.md.
What's next
Docker boxes are humming. Next lesson β actually start the dev servers. docker compose up -d for infra, pnpm dev for the apps, and you'll have URLs to click in 3 minutes.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub