
{"id":168784,"date":"2026-05-21T14:17:22","date_gmt":"2026-05-21T14:17:22","guid":{"rendered":"https:\/\/mycryptomania.com\/?p=168784"},"modified":"2026-05-21T14:17:22","modified_gmt":"2026-05-21T14:17:22","slug":"docker-in-2026-from-zero-to-production-ready-containers","status":"publish","type":"post","link":"https:\/\/mycryptomania.com\/?p=168784","title":{"rendered":"Docker in 2026: From Zero to Production-Ready Containers"},"content":{"rendered":"<p><strong>A complete, no-fluff guide to containers, images, Dockerfiles, Compose, volumes, and networking with real Go + Gin + PostgreSQL examples.<\/strong><\/p>\n<p><strong><em>Reading time:<\/em><\/strong><em> ~22 minutes <\/em><strong><em>Level:<\/em><\/strong><em> Developer who knows Go, new to Docker <\/em><strong><em>Stack used:<\/em><\/strong><em> Go (Gin framework) + PostgreSQL +\u00a0pgAdmin<\/em><\/p>\n<h3>Table of\u00a0contents<\/h3>\n<p>Why Docker exists the problem it\u00a0solvesImages vs containers the distinction that\u00a0mattersDocker commands cheatsheetWriting your first DockerfileDocker Compose managing multiple containersVolumes and data persistenceDocker networkingPutting it all together your complete\u00a0project<\/p>\n<h3>1. Why Docker exists the problem it\u00a0solves<\/h3>\n<p>Every Go developer has lived this moment: you write an API on your MacBook, it compiles and runs perfectly, you push to GitHub, and your teammate pulls it on their Linux workstation. go run main.go\u00a0panics.<\/p>\n<p>The database connection string that worked on your machine fails on theirs. The libc version is different. The environment variable you set in your\u00a0.zshrc six months ago and forgot about it&#8217;s not on their machine. The exact libpq version your PostgreSQL driver expects?\u00a0Wrong.<\/p>\n<p>Here is why this happens. When you build a Go application, it silently accumulates a set of environmental dependencies:<\/p>\n<p>A specific version of the Go toolchain (1.21, not 1.20, and definitely not\u00a01.19)The gcc compiler for CGO-dependent packagesSystem CA certificates for HTTPS calls to external\u00a0APIsEnvironment variables configured only in your local shell\u00a0profileThe exact libpq version your PostgreSQL driver\u00a0expects<\/p>\n<p>None of these are in your go.mod. They live invisibly on your\u00a0machine.<\/p>\n<p><strong>Docker solves this by packaging your application together with its entire environment into a single portable unit called a container.<\/strong><\/p>\n<p>Instead of saying \u201chere is my code, good luck running it\u201d, you now ship: <em>code + Go runtime + system libraries + configuration<\/em>, all bundled. Wherever that bundle runs your laptop, your teammate\u2019s Windows machine, a CI\/CD pipeline, a production server on AWS the behavior is identical.<\/p>\n<p>This is especially valuable in teams where onboarding a new developer used to mean half a day of \u201cinstall Go, configure PostgreSQL, set environment variables, hope glibc versions match.&#8221; With Docker, it becomes: docker compose up.\u00a0Done.<\/p>\n<h3>2. Images vs containers\u200a\u2014\u200athe distinction that\u00a0matters<\/h3>\n<p>This is where most beginners get confused. Docker has two core entities and understanding their relationship is the foundation of everything else.<\/p>\n<h3>Docker image<\/h3>\n<p>A Docker image is a <strong>read-only, static snapshot<\/strong> of what an environment should look like. It is not a running process. Think of it exactly like a struct definition in Go it holds the blueprint but does not allocate any runtime resources by\u00a0itself.<\/p>\n<p>Images are built from a file called a Dockerfile (covered in section 4). Once built, an image can be shared, versioned with tags, and stored in a registry like Docker\u00a0Hub.<\/p>\n<h3>Docker container<\/h3>\n<p>A container is a <strong>running instance created from an image<\/strong>. Just as you instantiate a struct in Go, you instantiate containers from images. A single image can produce many containers simultaneously, each isolated from the\u00a0others.<\/p>\n<p>Image  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25ba  Container 1 (running)<br \/>       \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25ba  Container 2 (running)<br \/>       \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25ba  Container 3 (stopped)<\/p>\n<h3>Key differences at a\u00a0glance<\/h3>\n<p>Property Image Container State Static, read-only Dynamic, has runtime state Resources No CPU\/RAM consumed Consumes CPU and RAM Mutability Immutable after build Writable layer on top of image Sharing Pushed\/pulled from registries Not directly shareable Go analogy struct type definition struct instance in\u00a0memory<\/p>\n<h3>Image layers<\/h3>\n<p>Every image is composed of stacked, immutable layers. When Docker builds an image, each instruction in the Dockerfile creates a new layer. This layered architecture has a critical performance benefit: <strong>layers are cached and\u00a0shared<\/strong>.<\/p>\n<p>If you have two images both based on golang:1.21-alpine, Docker only stores that base layer once on your machine. When you pull the second image, it reuses the cached layer instead of downloading it again. This is why pulling a second Go-based image shows &#8220;Already exists&#8221; for several layers\u200a\u2014\u200athose layers are\u00a0shared.<\/p>\n<h3>3. Docker commands cheatsheet<\/h3>\n<p>Before writing any Dockerfiles, you need to be fluent in the core CLI. Here is a complete reference with explanations for every\u00a0command.<\/p>\n<h3>Working with\u00a0images<\/h3>\n<p># Pull an image from Docker Hub<br \/>docker pull golang:1.21-alpine<\/p>\n<p>docker pull fetches an image from a registry to your local machine. The format is image_name:tag. If you omit the tag, Docker defaults to latest which can be unpredictable. Always specify a version tag for reproducibility.<\/p>\n<p># List all locally available images<br \/>docker images<\/p>\n<p>Shows every image on your machine repository name, tag, image ID, creation date, and size. Watch the SIZE column: a Go binary built with multi-stage builds (section 4) drops from ~350 MB to ~25\u00a0MB.<\/p>\n<p># Remove an image<br \/>docker rmi golang:1.21-alpine<\/p>\n<p>Deletes an image. Will fail if a container (even a stopped one) is still using it remove the container first.<\/p>\n<p># Remove all dangling (untagged) images<br \/>docker image prune<\/p>\n<p>Cleans up &lt;none&gt;:&lt;none&gt; images left behind from failed or replaced builds. Run this periodically to reclaim disk\u00a0space.<\/p>\n<h3>Working with containers<\/h3>\n<p># Create and start a container from an image<br \/>docker run golang:1.21-alpine<\/p>\n<p>The most fundamental command. It pulls the image if not available locally, creates a container, and runs the default command. For golang:1.21-alpine, that default is go version the container prints it and exits immediately.<\/p>\n<p># Run a container in detached (background) mode<br \/>docker run -d golang:1.21-alpine sleep 3600<\/p>\n<p>The -d flag detaches the container so it runs in the background. The sleep 3600 keeps it alive for an hour so you can inspect\u00a0it.<\/p>\n<p># Run a container interactively with a shell<br \/>docker run -it golang:1.21-alpine sh<\/p>\n<p>-i keeps stdin open; -t allocates a pseudo-TTY. Together, -it gives you an interactive shell inside the container. Inside, try go version and cat \/etc\/os-release to see the isolated environment.<\/p>\n<p># Run with port binding<br \/>docker run -d -p 8080:8080 my-gin-app<\/p>\n<p>-p host_port:container_port maps a port on your machine to a port inside the container. Without this, your Gin app running on port 8080 inside the container would be completely unreachable from your browser or\u00a0curl.<\/p>\n<p># Run with an environment variable<br \/>docker run -d <br \/>  -e DATABASE_URL=&#8221;host=localhost user=admin password=secret dbname=appdb port=5432 sslmode=disable&#8221; <br \/>  my-gin-app<\/p>\n<p>-e sets an environment variable inside the container at runtime. Go applications read these with os.Getenv(). This is how you inject secrets and configuration without hardcoding them into the\u00a0image.<\/p>\n<p># Give the container a human-readable name<br \/>docker run -d &#8211;name gin-api my-gin-app<\/p>\n<p>By default, Docker assigns a random name like peaceful_darwin. Using &#8211;name makes subsequent commands much\u00a0easier.<\/p>\n<p># List running containers<br \/>docker ps# List ALL containers (including stopped)<br \/>docker ps -a# Stop a running container (graceful SIGTERM, then SIGKILL after 10s)<br \/>docker stop gin-api# Start a stopped container<br \/>docker start gin-api# Remove a stopped container<br \/>docker rm gin-api# Force-remove a running container<br \/>docker rm -f gin-api<\/p>\n<h3>Inspecting and debugging<\/h3>\n<p># View logs of a container<br \/>docker logs gin-api# Follow logs in real time<br \/>docker logs -f gin-api<\/p>\n<p>docker logs is your first stop when a container misbehaves. It captures everything the process wrote to stdout and stderr. For Gin, this includes HTTP request logs and panic stack\u00a0traces.<\/p>\n<p># Execute a command inside a running container<br \/>docker exec -it gin-api sh<\/p>\n<p>docker exec runs an additional command inside an <strong>already running<\/strong> container you are not creating a new container, you are attaching to an existing one. Try env to see all environment variables, or cat \/etc\/resolv.conf to see DNS configuration.<\/p>\n<p># Inspect detailed container metadata<br \/>docker inspect gin-api | jq &#8216;.[0].NetworkSettings&#8217;<\/p>\n<p>Returns a JSON payload covering network settings, volume mounts, environment variables, resource limits. Pipe through jq for readable\u00a0output.<\/p>\n<p># Display real-time resource usage (CPU, memory, network I\/O)<br \/>docker stats<\/p>\n<h3>Managing networks and\u00a0volumes<\/h3>\n<p># List all Docker networks<br \/>docker network ls# Create a custom network<br \/>docker network create app-network# Remove a network<br \/>docker network rm app-network# List all volumes<br \/>docker volume ls# Create a named volume<br \/>docker volume create pg-data# Remove unused volumes<br \/>docker volume prune<\/p>\n<h3>4. Writing your first Dockerfile<\/h3>\n<p>A Dockerfile is a plain text file containing instructions that Docker executes top-to-bottom to build your image. Each instruction creates a new\u00a0layer.<\/p>\n<h3>The application we are dockerizing<\/h3>\n<p>A minimal Gin REST API that stores and retrieves users from PostgreSQL.<\/p>\n<p><strong>main.go<\/strong><\/p>\n<p>package mainimport (<br \/>    &#8220;net\/http&#8221;<br \/>    &#8220;os&#8221;<br \/>    &#8220;time&#8221;    &#8220;github.com\/gin-gonic\/gin&#8221;<br \/>    &#8220;gorm.io\/driver\/postgres&#8221;<br \/>    &#8220;gorm.io\/gorm&#8221;<br \/>)type User struct {<br \/>    ID        uint      `json:&#8221;id&#8221; gorm:&#8221;primaryKey&#8221;`<br \/>    Name      string    `json:&#8221;name&#8221;`<br \/>    Email     string    `json:&#8221;email&#8221;`<br \/>    CreatedAt time.Time `json:&#8221;created_at&#8221;`<br \/>}var DB *gorm.DBfunc initDB() {<br \/>    dsn := os.Getenv(&#8220;DATABASE_URL&#8221;)<br \/>    if dsn == &#8220;&#8221; {<br \/>        dsn = &#8220;host=localhost user=admin password=secret dbname=appdb port=5432 sslmode=disable&#8221;<br \/>    }<br \/>    var err error<br \/>    DB, err = gorm.Open(postgres.Open(dsn), &amp;gorm.Config{})<br \/>    if err != nil {<br \/>        panic(&#8220;failed to connect database: &#8221; + err.Error())<br \/>    }<br \/>    DB.AutoMigrate(&amp;User{})<br \/>}func main() {<br \/>    initDB()<br \/>    r := gin.Default()    r.GET(&#8220;\/users&#8221;, func(c *gin.Context) {<br \/>        var users []User<br \/>        DB.Find(&amp;users)<br \/>        c.JSON(http.StatusOK, users)<br \/>    })    r.POST(&#8220;\/users&#8221;, func(c *gin.Context) {<br \/>        var user User<br \/>        if err := c.ShouldBindJSON(&amp;user); err != nil {<br \/>            c.JSON(http.StatusBadRequest, gin.H{&#8220;error&#8221;: err.Error()})<br \/>            return<br \/>        }<br \/>        DB.Create(&amp;user)<br \/>        c.JSON(http.StatusCreated, user)<br \/>    })    r.Run(&#8220;:8080&#8221;)<br \/>}<\/p>\n<p><strong>go.mod<\/strong><\/p>\n<p>module gin-docker-demogo 1.21require (<br \/>    github.com\/gin-gonic\/gin v1.9.1<br \/>    gorm.io\/driver\/postgres v1.5.4<br \/>    gorm.io\/gorm v1.25.5<br \/>)<\/p>\n<h3>The Dockerfile\u200a\u2014\u200awith multi-stage builds<\/h3>\n<p>Multi-stage builds are the most important Go-specific Docker technique. The core idea: use one stage to <em>compile<\/em> the binary (needs the full Go toolchain), and a second stage to <em>run<\/em> it (needs almost nothing). The final image only ships the compiled\u00a0binary.<\/p>\n<p># \u2500\u2500 Stage 1: Build stage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<br \/>FROM golang:1.21-alpine AS builder<\/p>\n<p>FROM\u00a0&#8230; AS builder names this stage. golang:1.21-alpine is the official Go 1.21 image based on Alpine Linux (~350 MB). The AS builder alias lets us reference this stage&#8217;s filesystem in stage\u00a02.<\/p>\n<p># Install build dependencies<br \/>RUN apk add &#8211;no-cache git ca-certificates<\/p>\n<p>Alpine uses apk as its package manager. &#8211;no-cache prevents storing the package index in the layer. git is needed for fetching some Go modules; ca-certificates enables HTTPS validation during go mod download.<\/p>\n<p>WORKDIR \/app<\/p>\n<p>Sets the working directory for all subsequent instructions. Think of it as cd \/app if the directory doesn&#8217;t exist, Docker creates\u00a0it.<\/p>\n<p># Copy dependency files first \u2014 critical for layer caching<br \/>COPY go.mod go.sum .\/<\/p>\n<p>We copy go.mod and go.sum <em>before<\/em> the source code. This is deliberate. Docker caches each layer if go.mod hasn&#8217;t changed, the next go mod download step will be served entirely from cache, saving 30-60 seconds on every\u00a0build.<\/p>\n<p>RUN go mod download<\/p>\n<p>Downloads all modules into the local module cache. Because this layer is cached, subsequent builds where only\u00a0.go files changed skip this step entirely.<\/p>\n<p>COPY . .<\/p>\n<p>Now we copy the application source. Changing\u00a0.go files only invalidates this layer and below not the expensive go mod download step\u00a0above.<\/p>\n<p># Compile a fully static binary<br \/>RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .<\/p>\n<p>This is the key compilation step for Go containers:<\/p>\n<p>CGO_ENABLED=0 disables CGO, producing a <strong>fully static binary<\/strong> with zero dynamic library dependenciesGOOS=linux targets Linux regardless of the build machine&#8217;s OS (important if you&#8217;re building on\u00a0macOS)-aforces rebuilding all packages for a clean static\u00a0result-installsuffix cgo keeps the build cache separate from CGO\u00a0builds-o mainoutputs the binary named\u00a0main<\/p>\n<p>The result: a single executable that runs anywhere without any runtime dependencies.<\/p>\n<p># \u2500\u2500 Stage 2: Final minimal image \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<br \/>FROM alpine:latest<\/p>\n<p>We start fresh from alpine:latest (~5 MB). No Go toolchain. No source code. No module cache. The final image will only contain what&#8217;s needed to <em>run<\/em> the\u00a0binary.<\/p>\n<p>RUN apk &#8211;no-cache add ca-certificates<br \/>WORKDIR \/root\/<\/p>\n<p>Even a minimal image needs CA certificates for outbound HTTPS calls (payment APIs, webhooks, auth services).<\/p>\n<p># Pull the compiled binary from stage 1<br \/>COPY &#8211;from=builder \/app\/main .<\/p>\n<p>&#8211;from=builder reaches into the filesystem of stage 1 and copies exactly one file the compiled main binary. That&#8217;s it. No source, no compiler, no temporary files.<\/p>\n<p>EXPOSE 8080<br \/>CMD [&#8220;.\/main&#8221;]<\/p>\n<p>EXPOSE 8080 documents which port the application listens on. CMD in exec form (JSON array) makes your binary PID 1 directly it receives OS signals correctly, enabling graceful shutdown when Docker sends\u00a0SIGTERM.<\/p>\n<h3>Full Dockerfile<\/h3>\n<p># Stage 1 \u2014 Build<br \/>FROM golang:1.21-alpine AS builder<br \/>RUN apk add &#8211;no-cache git ca-certificates<br \/>WORKDIR \/app<br \/>COPY go.mod go.sum .\/<br \/>RUN go mod download<br \/>COPY . .<br \/>RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .# Stage 2 \u2014 Run<br \/>FROM alpine:latest<br \/>RUN apk &#8211;no-cache add ca-certificates<br \/>WORKDIR \/root\/<br \/>COPY &#8211;from=builder \/app\/main .<br \/>EXPOSE 8080<br \/>CMD [&#8220;.\/main&#8221;]<\/p>\n<h3>Build and verify the size difference<\/h3>\n<p># Build the image<br \/>docker build -t gin-app:1.0 .# See the dramatic size difference<br \/>docker images<br \/># REPOSITORY   TAG       SIZE<br \/># gin-app      1.0       ~25MB     \u2190 multi-stage: tiny<br \/># golang       1.21      ~350MB    \u2190 full image: huge# Run a container<br \/>docker run -d <br \/>  -p 8080:8080 <br \/>  -e DATABASE_URL=&#8221;host=localhost user=admin password=secret dbname=appdb port=5432 sslmode=disable&#8221; <br \/>  &#8211;name gin-api <br \/>  gin-app:1.0<\/p>\n<h3>.dockerignore<\/h3>\n<p># .dockerignore<br \/>*.md<br \/>.git\/<br \/>.env<br \/>.env.example<br \/>tmp\/<br \/>vendor\/<\/p>\n<p>Without this, Docker sends your entire project directory to the build daemon as the build context, slowing every build. With it, only the files Docker actually needs are transferred.<\/p>\n<h3>5. Docker Compose\u200a\u2014\u200amanaging multiple containers<\/h3>\n<p>Running a single container with docker run is manageable. But a real application has multiple services a Go API, a PostgreSQL database, a monitoring UI. Running each with a long docker run command, keeping them on the same network, managing startup order this becomes painful\u00a0fast.<\/p>\n<p>Meet Rahul, a backend engineer who just joined your team. On his first day, he clones the repository and runs docker compose up -d. Three minutes later, he has a fully working local environment: the Gin API on port 8080, PostgreSQL on port 5432, pgAdmin on port 8081. No &#8220;install PostgreSQL&#8221; guide. No &#8220;configure your local environment&#8221; wiki page. One\u00a0command.<\/p>\n<p><strong>Docker Compose<\/strong> makes this possible by letting you define your entire multi-container application in a single YAML\u00a0file.<\/p>\n<h3>The docker-compose.yml file<\/h3>\n<p>services:  # \u2500\u2500 Go\/Gin API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<br \/>  api:<br \/>    build: .<br \/>    container_name: gin-api<br \/>    ports:<br \/>      &#8211; &#8220;8080:8080&#8221;<br \/>    environment:<br \/>      &#8211; DATABASE_URL=host=postgres user=admin password=secret dbname=appdb port=5432 sslmode=disable<br \/>      &#8211; GIN_MODE=release<br \/>    depends_on:<br \/>      postgres:<br \/>        condition: service_healthy       # Wait until Postgres healthcheck passes<br \/>    networks:<br \/>      &#8211; app-network<br \/>    restart: unless-stopped  # \u2500\u2500 PostgreSQL database \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<br \/>  postgres:<br \/>    image: postgres:15-alpine<br \/>    container_name: pg-db<br \/>    environment:<br \/>      &#8211; POSTGRES_DB=appdb<br \/>      &#8211; POSTGRES_USER=admin<br \/>      &#8211; POSTGRES_PASSWORD=secret<br \/>    ports:<br \/>      &#8211; &#8220;5432:5432&#8221;<br \/>    volumes:<br \/>      &#8211; pg-data:\/var\/lib\/postgresql\/data<br \/>      &#8211; .\/init.sql:\/docker-entrypoint-initdb.d\/init.sql<br \/>    networks:<br \/>      &#8211; app-network<br \/>    healthcheck:<br \/>      test: [&#8220;CMD-SHELL&#8221;, &#8220;pg_isready -U admin -d appdb&#8221;]<br \/>      interval: 5s<br \/>      timeout: 5s<br \/>      retries: 5  # \u2500\u2500 pgAdmin (database UI) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<br \/>  pgadmin:<br \/>    image: dpage\/pgadmin4<br \/>    container_name: pg-admin<br \/>    environment:<br \/>      &#8211; PGADMIN_DEFAULT_EMAIL=admin@admin.com<br \/>      &#8211; PGADMIN_DEFAULT_PASSWORD=admin<br \/>    ports:<br \/>      &#8211; &#8220;8081:80&#8221;<br \/>    networks:<br \/>      &#8211; app-network<br \/>    depends_on:<br \/>      &#8211; postgresnetworks:<br \/>  app-network:<br \/>    driver: bridgevolumes:<br \/>  pg-data:<\/p>\n<h3>Breaking down the key\u00a0concepts<\/h3>\n<p><strong>Service names as hostnames<\/strong> The key postgres under services automatically becomes a DNS hostname inside the Docker network. This is why DATABASE_URL uses host=postgresCompose resolves postgres to that container&#8217;s internal IP address. No hardcoded IPs\u00a0needed.<\/p>\n<p><strong>depends_on with <\/strong><strong>condition: service_healthy<\/strong> The plain depends_on only guarantees startup <em>order<\/em>. With condition: service_healthy, Compose actually waits until the healthcheck on the postgres service passes before starting the api. This prevents the Go app from panicking on startup because the database isn&#8217;t ready\u00a0yet.<\/p>\n<p><strong>healthcheck on postgres<\/strong>pg_isready -U admin -d appdb probes whether PostgreSQL is actually accepting connections, not just whether the process is running. It retries every 5 seconds up to 5\u00a0times.<\/p>\n<p><strong>restart: unless-stopped<\/strong> If the api container crashes (e.g., a panic before the database is ready on a very slow machine), Docker restarts it automatically. Combined with the healthcheck condition, this makes the startup bulletproof.<\/p>\n<p><strong>init.sql mount<\/strong> The official PostgreSQL image runs every\u00a0.sql file in \/docker-entrypoint-initdb.d\/ on first initialization. Mount your schema file there and your tables are created automatically.<\/p>\n<h3>Core Compose\u00a0commands<\/h3>\n<p># Start all services \u2014 builds images if needed<br \/>docker compose up -d# Show running services and their status<br \/>docker compose ps# Follow logs across all services<br \/>docker compose logs -f# Follow logs for a specific service only<br \/>docker compose logs -f api# Open a psql shell inside the running postgres container<br \/>docker compose exec postgres psql -U admin -d appdb# Rebuild the api image after code changes<br \/>docker compose up -d &#8211;build api# Stop services (containers preserved)<br \/>docker compose stop# Stop and remove containers + networks (volumes preserved)<br \/>docker compose down# Stop, remove containers + networks + volumes<br \/>docker compose down -v<\/p>\n<h3>Environment variables and\u00a0.env\u00a0files<\/h3>\n<p># .env \u2014 never committed to Git<br \/>POSTGRES_PASSWORD=my_strong_password<br \/>PGADMIN_PASSWORD=admin_password<br \/>DATABASE_URL=host=postgres user=admin password=my_strong_password dbname=appdb port=5432 sslmode=disable# .env.example \u2014 committed to Git, no real values<br \/>POSTGRES_PASSWORD=<br \/>PGADMIN_PASSWORD=<br \/>DATABASE_URL=host=postgres user=admin password= dbname=appdb port=5432 sslmode=disable# docker-compose.yml \u2014 references variables, not values<br \/>postgres:<br \/>  environment:<br \/>    &#8211; POSTGRES_PASSWORD=${POSTGRES_PASSWORD}<\/p>\n<p>New team members run cp\u00a0.env.example\u00a0.env, fill in their own values, and docker compose up -d. Clean, secure, repeatable.<\/p>\n<h3>6. Volumes and data persistence<\/h3>\n<p>By default, a Docker container has a writable layer on top of its image. Any files your application writes database rows, uploaded files, logs live in this layer. The problem: when you remove the container with docker rm, that writable layer is permanently deleted. Every restart of your PostgreSQL container means an empty database.<\/p>\n<p><strong>Volumes are Docker\u2019s solution to data persistence.<\/strong> They exist independently of any container\u2019s lifecycle.<\/p>\n<h3>The three types of volume\u00a0mounts<\/h3>\n<p><strong>1. Named volumes<\/strong>\u200a\u2014\u200arecommended for databases<\/p>\n<p>volumes:<br \/>  &#8211; pg-data:\/var\/lib\/postgresql\/data<\/p>\n<p>Docker manages the storage location internally. You reference it by name. Named volumes are the preferred approach for persistent data because Docker handles creation, cleanup, and lifecycle you never need to know the exact host\u00a0path.<\/p>\n<p>docker volume create pg-data<br \/>docker volume inspect pg-data<br \/># Shows the actual path: \/var\/lib\/docker\/volumes\/pg-data\/_data<\/p>\n<p><strong>2. Bind mounts<\/strong>\u200a\u2014\u200arecommended for development<\/p>\n<p>volumes:<br \/>  &#8211; .\/api:\/app<\/p>\n<p>Binds a host directory directly into the container. Changes on either side are instantly reflected on the other. Ideal during development edit a\u00a0.go file in your editor, and a tool like air (Go live reload) picks it up inside the container without rebuilding the\u00a0image.<\/p>\n<p>docker run -d <br \/>  -v $(pwd)\/api:\/app <br \/>  -p 8080:8080 <br \/>  gin-app:1.0<\/p>\n<p><strong>3. Anonymous volumes<\/strong>\u200a\u2014\u200aephemeral temporary storage<\/p>\n<p>volumes:<br \/>  &#8211; \/app\/tmp<\/p>\n<p>Docker assigns a random hex name. Removed when the container is removed. Used when you need temporary isolated storage that should not\u00a0persist.<\/p>\n<h3>Verifying persistence<\/h3>\n<p># Start the stack<br \/>docker compose up -d# Insert a user<br \/>curl -X POST <a href=\"http:\/\/localhost:8080\/users\">http:\/\/localhost:8080\/users<\/a> <br \/>  -H &#8220;Content-Type: application\/json&#8221; <br \/>  -d &#8216;{&#8220;name&#8221;: &#8220;Alice&#8221;, &#8220;email&#8221;: &#8220;alice@example.com&#8221;}&#8217;# Verify<br \/>curl <a href=\"http:\/\/localhost:8080\/users\">http:\/\/localhost:8080\/users<\/a><br \/># [{&#8220;id&#8221;:1,&#8221;name&#8221;:&#8221;Alice&#8221;,&#8221;email&#8221;:&#8221;alice@example.com&#8221;,&#8221;created_at&#8221;:&#8221;2026-05-20T10:30:00Z&#8221;}]# Tear down containers \u2014 volumes are preserved<br \/>docker compose down# Bring everything back up<br \/>docker compose up -d# Alice is still there<br \/>curl <a href=\"http:\/\/localhost:8080\/users\">http:\/\/localhost:8080\/users<\/a><br \/># [{&#8220;id&#8221;:1,&#8221;name&#8221;:&#8221;Alice&#8221;,&#8221;email&#8221;:&#8221;alice@example.com&#8221;,&#8221;created_at&#8221;:&#8221;2026-05-20T10:30:00Z&#8221;}]<\/p>\n<p>Without the named volume, the second query returns an empty array. With it, Alice survives container deletion.<\/p>\n<p># Clean up unused volumes<br \/>docker volume prune# Nuclear option \u2014 remove containers AND volumes<br \/>docker compose down -v<\/p>\n<h3>7. Docker networking<\/h3>\n<p>Every container you run gets connected to a Docker network. Networking governs how containers communicate with each other, with your host machine, and with the outside\u00a0world.<\/p>\n<h3>Default networks Docker\u00a0creates<\/h3>\n<p>docker network ls<br \/># NETWORK ID     NAME      DRIVER    SCOPE<br \/># abc123         bridge    bridge    local<br \/># def456         host      host      local<br \/># ghi789         none      null      local<\/p>\n<p><strong>bridge (default)<\/strong> Every container without an explicit network joins this. Containers can communicate by IP address but <em>not<\/em> by name there is no automatic DNS. Always create custom bridge networks\u00a0instead.<\/p>\n<p><strong>host<\/strong>The container shares the host&#8217;s network stack directly. No isolation the container&#8217;s port 8080 <em>is<\/em> the host&#8217;s port 8080, no -p mapping needed. Useful for performance-sensitive scenarios but sacrifices isolation.<\/p>\n<p><strong>none<\/strong>Complete isolation. No network interfaces except loopback. Used for batch containers that should have zero network\u00a0access.<\/p>\n<h3>Custom bridge networks\u200a\u2014\u200aalways use\u00a0these<\/h3>\n<p>When containers share a custom bridge network, Docker provides automatic DNS resolution. Containers reach each other by service\u00a0name.<\/p>\n<p># Create a custom network<br \/>docker network create app-network# Run postgres on it<br \/>docker run -d <br \/>  &#8211;name postgres <br \/>  &#8211;network app-network <br \/>  -e POSTGRES_PASSWORD=secret <br \/>  postgres:15-alpine# Run Gin app on the same network<br \/># It reaches postgres using the hostname &#8220;postgres&#8221;<br \/>docker run -d <br \/>  &#8211;name gin-api <br \/>  &#8211;network app-network <br \/>  -p 8080:8080 <br \/>  -e DATABASE_URL=&#8221;host=postgres user=admin password=secret dbname=appdb port=5432 sslmode=disable&#8221; <br \/>  gin-app:1.0<\/p>\n<p>Container ports are internal by default. A Gin app on port 8080 inside a container is unreachable from your browser without explicit\u00a0binding.<\/p>\n<p>docker run -p 8080:8080 gin-app:1.0<br \/>#              ^     ^<br \/>#          host  container# Bind a different host port if 8080 is taken<br \/>docker run -p 9090:8080 gin-app:1.0<\/p>\n<p><strong>Critical rule:<\/strong> Two containers cannot bind the same host port. Starting a second container on port 8080 when one is already using it throws a \u201cport already allocated\u201d error. Each container needs a unique host port, even when their internal container ports are the\u00a0same.<\/p>\n<h3>Inspecting networking<\/h3>\n<p># See all containers on a network and their IPs<br \/>docker network inspect app-network# See which networks a container is on<br \/>docker inspect gin-api | jq &#8216;.[0].NetworkSettings.Networks&#8217;# Add a running container to an additional network<br \/>docker network connect app-network my-other-container# Remove it<br \/>docker network disconnect app-network my-other-container<\/p>\n<h3>How Compose handles networking automatically<\/h3>\n<p>Compose creates a default bridge network named &lt;project-name&gt;_default and connects every service automatically. Service names become resolvable hostnames with no extra configuration.<\/p>\n<p>Declaring a custom network explicitly (as in section 5) is still recommended it gives a predictable name, makes intent clear, and lets you run docker network inspect app-network to debug connectivity issues.<\/p>\n<h3>8. Putting it all together\u200a\u2014\u200ayour complete\u00a0project<\/h3>\n<p>After working through all seven sections, your project should be organized like\u00a0this:<\/p>\n<p>gin-docker-demo\/<br \/>\u251c\u2500\u2500 main.go<br \/>\u251c\u2500\u2500 go.mod<br \/>\u251c\u2500\u2500 go.sum<br \/>\u251c\u2500\u2500 Dockerfile<br \/>\u251c\u2500\u2500 docker-compose.yml<br \/>\u251c\u2500\u2500 .env                   \u2190 not committed to Git<br \/>\u251c\u2500\u2500 .env.example           \u2190 template for new developers<br \/>\u251c\u2500\u2500 .dockerignore<br \/>\u2514\u2500\u2500 init.sql               \u2190 database initialization script<\/p>\n<p><strong>init.sql<\/strong><\/p>\n<p>CREATE TABLE IF NOT EXISTS users (<br \/>    id         SERIAL PRIMARY KEY,<br \/>    name       VARCHAR(100)        NOT NULL,<br \/>    email      VARCHAR(255) UNIQUE NOT NULL,<br \/>    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP<br \/>);<\/p>\n<p>Mounted into docker-entrypoint-initdb.d\/, PostgreSQL runs this automatically on first startup. Your schema is always ready before the Go app connects.<\/p>\n<h3>The complete developer workflow<\/h3>\n<p># Clone on any machine<br \/>git clone https:\/\/github.com\/you\/gin-docker-demo<br \/>cd gin-docker-demo# Set up environment variables<br \/>cp .env.example .env<br \/># Fill in POSTGRES_PASSWORD and DATABASE_URL in .env# Build images and start everything<br \/>docker compose up -d &#8211;build# Verify all services are healthy<br \/>docker compose ps# Watch the Go app logs \u2014 confirm it connected to PostgreSQL<br \/>docker compose logs -f api<br \/># [GIN-debug] Listening and serving HTTP on :8080<br \/># [GIN] 200 | GET \/users# Test the API<br \/>curl <a href=\"http:\/\/localhost:8080\/users\">http:\/\/localhost:8080\/users<\/a># Open pgAdmin<br \/># <a href=\"http:\/\/localhost:8081\/\">http:\/\/localhost:8081<\/a><br \/># Login: admin@admin.com \/ admin<br \/># Add server: host=postgres, port=5432, user=admin, password=&lt;from .env&gt;# Rebuild after code changes (only the api service)<br \/>docker compose up -d &#8211;build api# Done for the day<br \/>docker compose stop# Full cleanup \u2014 containers and networks removed, volumes preserved<br \/>docker compose down<\/p>\n<h3>Quick reference card<\/h3>\n<h3>Final thoughts<\/h3>\n<p>Docker has gone from a niche DevOps tool to a baseline expectation in professional software development. The mental model shift it demands from \u201csoftware installed on a machine\u201d to \u201csoftware packaged with its environment\u201d is the only real hurdle. Once that clicks, the rest follows naturally.<\/p>\n<p><strong>The five principles to internalize:<\/strong><\/p>\n<p><strong>Images are blueprints. Containers are instances.<\/strong> One image, many containers just like a struct type and its instances.<strong>Layers are your cache.<\/strong> Order Dockerfile instructions from least to most frequently changed. Dependencies before source code,\u00a0always.<strong>Multi-stage builds are non-negotiable for Go.<\/strong> CGO_ENABLED=0 + a scratch or alpine final stage takes you from 350 MB to 25\u00a0MB.<strong>Networks are how containers talk.<\/strong> Always use custom bridge networks. Service names resolve automatically in\u00a0Compose.<strong>Volumes are how data survives.<\/strong> Named volumes for databases, bind mounts for development hot-reload.<\/p>\n<p>Master these five principles and you have everything you need to containerize any Go application, eliminate \u201cworks on my machine\u201d incidents, and hand your project to any pipeline CI\/CD, staging, or production with confidence.<\/p>\n<p><em>Found this useful? Share it with a Go developer who is still manually installing PostgreSQL. They will thank\u00a0you.<\/em><\/p>\n<p><a href=\"https:\/\/medium.com\/coinmonks\/docker-in-2026-from-zero-to-production-ready-containers-63a485daaed5\">Docker in 2026: From Zero to Production-Ready Containers<\/a> was originally published in <a href=\"https:\/\/medium.com\/coinmonks\">Coinmonks<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>","protected":false},"excerpt":{"rendered":"<p>A complete, no-fluff guide to containers, images, Dockerfiles, Compose, volumes, and networking with real Go + Gin + PostgreSQL examples. Reading time: ~22 minutes Level: Developer who knows Go, new to Docker Stack used: Go (Gin framework) + PostgreSQL +\u00a0pgAdmin Table of\u00a0contents Why Docker exists the problem it\u00a0solvesImages vs containers the distinction that\u00a0mattersDocker commands cheatsheetWriting [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":168785,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-168784","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-interesting"],"_links":{"self":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/168784"}],"collection":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=168784"}],"version-history":[{"count":0,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/168784\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/media\/168785"}],"wp:attachment":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=168784"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=168784"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=168784"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}