A Docker primer

What Docker is, how Grit uses it, how to run Grit without it, and the commands you actually need.

12 mineasy

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

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ IMAGE β”‚ β”‚ A read-only template. "postgres:16-alpine" is an image.β”‚ β”‚ You download it once. β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ docker run / docker compose up β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ CONTAINER β”‚ β”‚ A live, running instance of an image. You can have manyβ”‚ β”‚ containers from the same image (3 postgres containers, β”‚ β”‚ each with their own data). β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ writes to β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ VOLUME β”‚ β”‚ A named, persistent disk attached to a container. When β”‚ β”‚ you delete the container, the volume's data stays. β”‚ β”‚ postgres-data, redis-data are volumes. β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Image = recipe. Container = running instance. Volume = data that survives container restarts.

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 docker group so you don't need sudo for every command: sudo usermod -aG docker $USER then log out and back in.

Verify the install:

docker --version
# Docker version 27.x.x
docker compose version
# Docker Compose version v2.x.x
docker 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.

Why non-default host ports? The container-internal ports stay at the canonical defaults (Postgres 5432, Redis 6379, MinIO 9000/9001) β€” that's what code inside the Docker network expects. The host ports (what you type on your laptop) are deliberately offset to dodge the most common collisions: a native Postgres install on 5432, a Memurai / brew Redis on 6379, Portainer on 9000. Inside the compose file each line looks like "127.0.0.1:5434:5432" β€” host port left, container port right.
Grit binds these ports to 127.0.0.1 only. That means only your laptop can connect to them β€” not anything else on your wifi. The default Docker behaviour is to bind to 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 background
docker 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 running
docker ps
docker 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 bash
docker compose exec postgres psql -U grit -d my-first-grit
# Restart one service after editing its config
docker compose restart redis
# Pull newer image versions (e.g., postgres 16.2 β†’ 16.3)
docker compose pull
# See disk usage β€” containers + volumes + images
docker 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.yml to one outside the reserved range (e.g., 5434 as Grit's defaults already do), and set POSTGRES_PORT=5434 in .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 winnat
    net 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_URL in .env AND let it disagree with POSTGRES_*. Solution: set one OR the other, not both. DATABASE_URL wins when set β€” it's the "external Postgres" escape hatch (Neon, Supabase, RDS).
  • Change POSTGRES_PASSWORD in .env after Postgres has already initialised its data volume.
The Postgres volume gotcha: Postgres reads 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 volume
docker compose up -d # Postgres re-initialises with the new password from .env
grit migrate # recreate the schema
grit seed # re-seed dev data
Yes, this destroys local DB data. That's why you commit a seeder β€” five seconds with 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:

ServiceProviderFree tier?env var
PostgresNeonYes β€” 0.5 GBDATABASE_URL
RedisUpstashYes β€” 10k commands/dayREDIS_URL
Object storageCloudflare R2Yes β€” 10 GB, no egress feesSTORAGE_*
EmailResendYes β€” 3k/month, 1 domainRESEND_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:

  1. 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 .env values from a shared password manager. Big difference at scale.
  2. Reproducibility. Same Postgres version, same Redis version, same MinIO bucket policy, every dev. Bugs reproduce; fixes stay fixed.
  3. 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

You ran `docker compose down` last night. This morning you run `docker compose up -d` and the database is empty. What happened?

Try it

Get comfortable with the daily Docker loop:

  1. Make sure Docker Desktop (or your Linux daemon) is running. Run docker run --rm hello-world β€” confirm it works.
  2. In your my-first-grit folder, run docker compose up -d. Then docker compose ps β€” confirm 4 services are healthy or running.
  3. Open Mailhog at http://localhost:8025 β€” confirm the inbox loads (empty is fine).
  4. Open MinIO at http://localhost:9003 β€” login minioadmin / minioadmin, confirm you see the console.
  5. 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".
  6. Run docker compose down (no -v!) and confirm the containers stop. Run docker 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