← Back to list

How to Convert Your Fabric Test Network into a Production-Ready Blockchain with CI/CD

If you have been working with Hyperledger Fabric’s test network, you know how satisfying it feels when everything works. Your chaincode…

Muhammad Talha in CoinsBench · 2026-03-24 07:25 · 0 claps · 23.7 min read
#blockchain #hyperledger #hyperledger-fabric #private-blockchain #consortium-blockchain
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3 ☁️ · DevOps & Cloud

How to Convert Your Fabric Test Network into a Production-Ready Blockchain with CI/CD

If you have been working with Hyperledger Fabric’s test network, you know how satisfying it feels when everything works. Your chaincode runs, your API connects, queries return data. You type ./network.sh up and a blockchain appears like magic.

Then someone says: deploy this to production.

And you stare at your screen.

This guide walks through every single step of converting a Fabric test network into a production-ready deployment. Not just commands to copy — but why each command exists, what each file does, and what happens behind the scenes. By the end you will have a real blockchain network running on a server, with a CI/CD pipeline that deploys your code automatically every time you push to GitHub.

What You Will Build

Mac (your laptop)          Ubuntu Server (production)
─────────────────          ──────────────────────────
Edit code                  Fabric CA (certificates)
git push           ──→     Orderer (transaction ordering)
                           Peer + CouchDB (blockchain data)
                           Go REST API (port 8080)
                           GitHub Actions Runner (CI/CD)

Every time you push code on your Mac, the server automatically pulls it and rebuilds. No SSH. No manual copying. Just git push.

Part 1 — Why the Test Network Cannot Be Used in Production

Before writing a single file, understand exactly why the test network fails in production.

Problem 1: Certificates Expire and Cannot Be Renewed

The test network uses a tool called cryptogen. It generates all certificates at once. They expire after exactly one year. When day 366 arrives, every certificate in your network becomes invalid simultaneously. The peer rejects connections. The orderer rejects connections. Your API cannot submit transactions. The entire network stops.

There is no renewal command. There is no way to fix it without regenerating everything from scratch, which means losing your channel, your chaincode, and your data.

cryptogen: creates all certificates at once
           expires after 1 year
           cannot renew
           cannot add new users
Fabric CA: issues certificates on demand
           can renew certificates before expiry
           can add new users at any time
           can revoke compromised certificates

Problem 2: Hardcoded Passwords Everywhere

Open any file in the test network:

command: sh -c 'fabric-ca-server start -b admin:adminpw -d'

The password adminpw appears in dozens of files. Every developer who has ever used fabric-samples knows this password. It is not a secret.

In production you need real passwords that you control, stored in one place.

Problem 3: No Persistent Storage

The test network stores blockchain data inside Docker containers without named volumes. When a container restarts, all data is gone. Every transaction, every DID record, every block — erased.

In production, data must survive reboots.

Problem 4: No Auto-Restart

If any container crashes at 3am, it stays down until someone manually restarts it. No alerts. No recovery. Just downtime.

Problem 5: No Backups

A disk failure means every transaction is permanently gone. There is no backup infrastructure in the test network.

Problem 6: Cannot Add Users Dynamically

cryptogen generates a fixed set of identities at startup. If you need to add a new application user later, you cannot. You must regenerate everything.

Part 2 — The Architecture

Here is what the production network looks like:

┌─────────────────────────────────────────────────────────┐
│                    Ubuntu Server                        │
│                                                         │
│  ┌─────────────┐  ┌─────────────┐                       │
│  │  CA Org1    │  │ CA Orderer  │  ← Certificate        │
│  │  port 7054  │  │  port 9054  │    Authorities        │
│  └─────────────┘  └─────────────┘                       │
│                                                         │
│  ┌─────────────┐  ┌─────────────┐  ┌──────────────┐     │
│  │  CouchDB    │  │   Orderer   │  │    Peer0     │     │
│  │  port 5984  │  │  port 7050  │  │  port 7051   │     │
│  └─────────────┘  └─────────────┘  └──────────────┘     │
│                                                         │
│  ┌──────────────────────────────────────────────────┐   │
│  │           blockchain-api  port 8080              │   │
│  └──────────────────────────────────────────────────┘   │
│                                                         │
│  ┌──────────────────────────────────────────────────┐   │
│  │    GitHub Actions Runner (CI/CD background)      │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

Part 3 — Folder Structure

Test Network (before)

fabric-samples/
├── test-network/
│   ├── network.sh              ← hardcoded everything
│   ├── configtx/configtx.yaml  ← has Org2 you don't need
│   ├── compose/
│   │   ├── compose-ca.yaml
│   │   ├── compose-test-net.yaml
│   │   └── compose-couch.yaml
│   └── scripts/
├── asset-transfer-basic/        ← chaincode
└── blockchain-service/          ← Go API

Production (after)

did-production/
├── .env                          ← ALL passwords and settings
├── .env.example                  ← Template (safe to commit)
├── .gitignore
├── network.sh                    ← Single command orchestrator
├── setup-server.sh               ← One-time server setup
├── .github/
│   └── workflows/
│       └── deploy.yml            ← CI/CD pipeline
├── configtx/
│   └── configtx.yaml
├── compose/
│   └── docker-compose.yaml       ← All 6 containers in one file
├── config/
│   └── core.yaml                 ← Peer configuration
├── organizations/
│   └── fabric-ca/
│       ├── org1/
│       │   └── fabric-ca-server-config.yaml
│       └── ordererOrg/
│           └── fabric-ca-server-config.yaml
├── scripts/
│   ├── registerEnroll.sh         ← Certificate generation
│   ├── createChannel.sh          ← Channel creation
│   ├── deployCC.sh               ← Chaincode deployment
│   ├── envVar.sh                 ← Environment helpers
│   ├── orderer.sh                ← Orderer channel join
│   ├── setAnchorPeer.sh
│   ├── configUpdate.sh
│   ├── packageCC.sh
│   ├── ccutils.sh
│   ├── utils.sh
│   ├── backup.sh                 ← Daily backup
│   └── renewCerts.sh             ← Certificate renewal
├── chaincode/
│   └── chaincode-go/             ← Your smart contract
├── blockchain-service/           ← Your Go REST API
│   ├── Dockerfile
│   ├── cmd/server/main.go
│   ├── config/config.go
│   ├── fabric/chaincodesdk.go
│   ├── handlers/
│   ├── models/
│   ├── routes/
│   └── services/
├── channel-artifacts/            ← Auto-generated (not committed)
├── backups/                      ← Daily backups
└── logs/                         ← Script logs

Part 4 — Creating Every File

4.1 — The .env File (Most Important)

Every password, every setting, every configurable value lives in this one file. Change one value here and it updates everywhere automatically.

File: .env (never commit this)

# ═══════════════════════════════════════════
# PRODUCTION ENVIRONMENT CONFIG
# Change values here — updates everywhere
# NEVER commit this file to git
# ═══════════════════════════════════════════
COMPOSE_PROJECT_NAME=fabric-did
NETWORK_NAME=fabric-did-network
ORG_NAME=Org1
ORG_MSP=Org1MSP
DOMAIN=org1.example.com
ORDERER_DOMAIN=example.com
CHANNEL_NAME=didchannel
# CA Ports
CA_ORG1_PORT=7054
CA_ORDERER_PORT=9054
# CA Admin Credentials
# These must match fabric-ca-server-config.yaml files
CA_ORG1_ADMIN_USER=admin
CA_ORG1_ADMIN_PASS=org1CAAdminPw
CA_ORDERER_ADMIN_USER=admin
CA_ORDERER_ADMIN_PASS=ordererCAAdminPw
# Identity Passwords
# Used when registering identities with Fabric CA
PEER0_PW=peer0pw
ORG1_ADMIN_PW=org1adminpw
APP_USER_PW=appuserpw
ORDERER_PW=ordererpw
ORDERER_ADMIN_PW=ordererAdminpw
# CouchDB
COUCHDB0_USER=admin
COUCHDB0_PASSWORD=couchdb0pw
# Chaincode Settings
CHAINCODE_NAME=basic
CHAINCODE_VERSION=1.0
CHAINCODE_SEQUENCE=1
CHAINCODE_LANG=go
CHAINCODE_PATH=./chaincode/chaincode-go
# Go API
API_PORT=8080
# Fabric Image Versions (pin to exact versions — never use :latest)
FABRIC_IMAGE_TAG=2.5.15
CA_IMAGE_TAG=1.5.15
COUCHDB_IMAGE_TAG=3.4.2
# Docker socket path
# Linux server: /var/run/docker.sock
# Mac Docker Desktop: /Users/YOUR_USERNAME/.docker/run/docker.sock
DOCKER_SOCK_PATH=/var/run/docker.sock
# Backup
BACKUP_RETENTION_DAYS=7
# Skip Go vendor directory during chaincode package
# Set true to avoid large Docker build contexts
SKIP_GO_VENDOR=true

File: .env.example (safe to commit — no real secrets)

# Copy this to .env and fill in real values
COMPOSE_PROJECT_NAME=fabric-did
NETWORK_NAME=fabric-did-network
ORG_NAME=Org1
ORG_MSP=Org1MSP
DOMAIN=org1.example.com
ORDERER_DOMAIN=example.com
CHANNEL_NAME=didchannel
CA_ORG1_PORT=7054
CA_ORDERER_PORT=9054
CA_ORG1_ADMIN_USER=admin
CA_ORG1_ADMIN_PASS=CHANGE_ME
CA_ORDERER_ADMIN_USER=admin
CA_ORDERER_ADMIN_PASS=CHANGE_ME
PEER0_PW=CHANGE_ME
ORG1_ADMIN_PW=CHANGE_ME
APP_USER_PW=CHANGE_ME
ORDERER_PW=CHANGE_ME
ORDERER_ADMIN_PW=CHANGE_ME
COUCHDB0_USER=admin
COUCHDB0_PASSWORD=CHANGE_ME
CHAINCODE_NAME=basic
CHAINCODE_VERSION=1.0
CHAINCODE_SEQUENCE=1
CHAINCODE_LANG=go
CHAINCODE_PATH=./chaincode/chaincode-go
API_PORT=8080
FABRIC_IMAGE_TAG=2.5.15
CA_IMAGE_TAG=1.5.15
COUCHDB_IMAGE_TAG=3.4.2
DOCKER_SOCK_PATH=/var/run/docker.sock
BACKUP_RETENTION_DAYS=7
SKIP_GO_VENDOR=true

File: .gitignore

# Never commit these
.env
.env.local
# Generated crypto material (contains private keys)
organizations/ordererOrganizations/
organizations/peerOrganizations/
organizations/fabric-ca/org1/ca-cert.pem
organizations/fabric-ca/org1/tls-cert.pem
organizations/fabric-ca/ordererOrg/ca-cert.pem
organizations/fabric-ca/ordererOrg/tls-cert.pem
# Generated channel artifacts
channel-artifacts/
# Chaincode packages
*.tar.gz
# Logs and temporary files
log.txt
anchor_update.err
backups/
logs/
# OS files
.DS_Store
# Build output
blockchain-service/bin/

4.2 — CA Configuration Files

Why Two CA Files Exist

The test network uses two CAs: one for the orderer organization and one for Org1. Each CA issues certificates only for its own organization. This separation means if one CA is compromised, it does not affect the other.

File: organizations/fabric-ca/ordererOrg/fabric-ca-server-config.yaml

Copy from test-network/organizations/fabric-ca/ordererOrg/fabric-ca-server-config.yaml, then make these exact changes:

Change 1 — Port (line ~43):

# BEFORE:
port: 7054
# AFTER:
port: 9054

Why: Orderer CA uses 9054. Org1 CA uses 7054. They cannot share a port.

Change 2 — CA Name (line ~83):

# BEFORE:
ca:
  name: OrdererCA
# AFTER:
ca:
  name: ca-orderer

Why: The name ca-orderer is what registerEnroll.sh passes with --caname when connecting.

Change 3 — Admin Password (line ~126):

# BEFORE:
  identities:
     - name: admin
       pass: adminpw
# AFTER:
  identities:
     - name: admin
       pass: ordererCAAdminPw

Why: This must match CA_ORDERER_ADMIN_PASS in your .env. The default adminpw is publicly known.

File: organizations/fabric-ca/org1/fabric-ca-server-config.yaml

Copy from test-network/organizations/fabric-ca/org1/fabric-ca-server-config.yaml, then make only this change:

Change — Admin Password (line ~126):

# BEFORE:
  identities:
     - name: admin
       pass: adminpw
# AFTER:
  identities:
     - name: admin
       pass: org1CAAdminPw

Everything else in this file stays the same. Port stays 7054. CA name stays Org1CA.

4.3 — configtx.yaml (Network Blueprint)

This file defines the entire structure of your blockchain network: which organizations exist, what the policies are, how the orderer behaves. The configtxgen tool reads this file to generate the genesis block and channel configuration.

File: configtx/configtx.yaml

Start by copying from test-network/configtx/configtx.yaml, then make these changes:

Change 1 — Delete the entire Org2 section

Find and delete everything from - &Org2 down to and including the last line of the Org2 block. You have one organization. Keeping Org2 causes configtxgen to fail looking for certificates that do not exist.

Change 2 — Add Endorsement policy and AnchorPeers to Org1

- &Org1
    Name: Org1MSP
    ID: Org1MSP
    MSPDir: ../organizations/peerOrganizations/org1.example.com/msp
    Policies:
      Readers:
        Type: Signature
        Rule: "OR('Org1MSP.admin', 'Org1MSP.peer', 'Org1MSP.client')"
      Writers:
        Type: Signature
        Rule: "OR('Org1MSP.admin', 'Org1MSP.client')"
      Admins:
        Type: Signature
        Rule: "OR('Org1MSP.admin')"
      # ADD THIS — required for chaincode commit to succeed
      Endorsement:
        Type: Signature
        Rule: "OR('Org1MSP.peer')"
    # ADD THIS — required for gossip protocol
    AnchorPeers:
      - Host: peer0.org1.example.com
        Port: 7051

Why Endorsement matters: When you commit chaincode, Fabric checks that a majority endorsement policy exists. Without it, peer lifecycle chaincode commit fails with ENDORSEMENT_POLICY_FAILURE.

Change 3 — Increase BatchSize for production throughput

BatchSize:
    # BEFORE: MaxMessageCount: 10
    # AFTER:
    MaxMessageCount: 500
    # BEFORE: AbsoluteMaxBytes: 99 MB
    # AFTER:
    AbsoluteMaxBytes: 10 MB
    # BEFORE: PreferredMaxBytes: 512 KB
    # AFTER:
    PreferredMaxBytes: 2 MB

Why: Test network batches only 10 transactions. Production needs higher throughput.

Change 4 — Remove Org2 from Profiles

Find the Profiles section at the bottom. Remove the commented # - *Org2 line completely:

Profiles:
  ChannelUsingRaft:
    <<: *ChannelDefaults
    Orderer:
      <<: *OrdererDefaults
      OrdererType: etcdraft
      EtcdRaft:
        Consenters:
          - Host: orderer.example.com
            Port: 7050
            ClientTLSCert: ../organizations/ordererOrganizations/example.com/orderers/orderer.example.com/tls/server.crt
            ServerTLSCert: ../organizations/ordererOrganizations/example.com/orderers/orderer.example.com/tls/server.crt
      Organizations:
        - *OrdererOrg
      Capabilities: *OrdererCapabilities
    Application:
      <<: *ApplicationDefaults
      Organizations:
        - *Org1
        # DELETE the commented - *Org2 line
      Capabilities: *ApplicationCapabilities

4.4 — docker-compose.yaml (The Heart of Production)

This single file replaces the three separate compose files from the test network (compose-ca.yaml, compose-test-net.yaml, compose-couch.yaml).

File: compose/docker-compose.yaml

volumes:
  ca_org1_data:
  ca_orderer_data:
  orderer.example.com:
  peer0.org1.example.com:
  couchdb0_data:
networks:
  fabric-did-network:
    name: ${NETWORK_NAME}
services:
  # ─────────────────────────────────────────
  # CA FOR ORG1
  # Issues certificates for: peer0, admin, appUser
  # Must start before all other services
  # ─────────────────────────────────────────
  ca_org1:
    image: hyperledger/fabric-ca:${CA_IMAGE_TAG}
    container_name: ca_org1
    environment:
      - FABRIC_CA_HOME=/etc/hyperledger/fabric-ca-server
      - FABRIC_CA_SERVER_CA_NAME=ca-org1
      - FABRIC_CA_SERVER_TLS_ENABLED=true
      - FABRIC_CA_SERVER_PORT=7054
      - FABRIC_CA_SERVER_OPERATIONS_LISTENADDRESS=0.0.0.0:17054
    ports:
      - "${CA_ORG1_PORT}:7054"
      - "17054:17054"
    command: sh -c 'fabric-ca-server start -b ${CA_ORG1_ADMIN_USER}:${CA_ORG1_ADMIN_PASS} -d'
    volumes:
      - ca_org1_data:/etc/hyperledger/fabric-ca-server
      - ../organizations/fabric-ca/org1:/etc/hyperledger/fabric-ca-server/config
    networks:
      - fabric-did-network
    restart: unless-stopped
  # ─────────────────────────────────────────
  # CA FOR ORDERER ORG
  # Issues certificates for: orderer, ordererAdmin
  # ─────────────────────────────────────────
  ca_orderer:
    image: hyperledger/fabric-ca:${CA_IMAGE_TAG}
    container_name: ca_orderer
    environment:
      - FABRIC_CA_HOME=/etc/hyperledger/fabric-ca-server
      - FABRIC_CA_SERVER_CA_NAME=ca-orderer
      - FABRIC_CA_SERVER_TLS_ENABLED=true
      - FABRIC_CA_SERVER_PORT=9054
      - FABRIC_CA_SERVER_OPERATIONS_LISTENADDRESS=0.0.0.0:19054
    ports:
      - "${CA_ORDERER_PORT}:9054"
      - "19054:19054"
    command: sh -c 'fabric-ca-server start -b ${CA_ORDERER_ADMIN_USER}:${CA_ORDERER_ADMIN_PASS} -d'
    volumes:
      - ca_orderer_data:/etc/hyperledger/fabric-ca-server
      - ../organizations/fabric-ca/ordererOrg:/etc/hyperledger/fabric-ca-server/config
    networks:
      - fabric-did-network
    restart: unless-stopped
  # ─────────────────────────────────────────
  # COUCHDB
  # World state database for peer0
  # Stores current values of all assets
  # ─────────────────────────────────────────
  couchdb0:
    image: couchdb:${COUCHDB_IMAGE_TAG}
    container_name: couchdb0
    environment:
      - COUCHDB_USER=${COUCHDB0_USER}
      - COUCHDB_PASSWORD=${COUCHDB0_PASSWORD}
    ports:
      - "${COUCHDB0_PORT:-5984}:5984"
    volumes:
      - couchdb0_data:/opt/couchdb/data
    networks:
      - fabric-did-network
    restart: unless-stopped
  # ─────────────────────────────────────────
  # ORDERER
  # Orders all transactions into blocks
  # Uses channel participation API (no genesis block file needed)
  # ─────────────────────────────────────────
  orderer.example.com:
    image: hyperledger/fabric-orderer:${FABRIC_IMAGE_TAG}
    container_name: orderer.example.com
    environment:
      - FABRIC_LOGGING_SPEC=INFO
      - ORDERER_GENERAL_LISTENADDRESS=0.0.0.0
      - ORDERER_GENERAL_LISTENPORT=7050
      - ORDERER_GENERAL_LOCALMSPID=OrdererMSP
      - ORDERER_GENERAL_LOCALMSPDIR=/var/hyperledger/orderer/msp
      - ORDERER_GENERAL_TLS_ENABLED=true
      - ORDERER_GENERAL_TLS_PRIVATEKEY=/var/hyperledger/orderer/tls/server.key
      - ORDERER_GENERAL_TLS_CERTIFICATE=/var/hyperledger/orderer/tls/server.crt
      - ORDERER_GENERAL_TLS_ROOTCAS=[/var/hyperledger/orderer/tls/ca.crt]
      - ORDERER_GENERAL_CLUSTER_CLIENTCERTIFICATE=/var/hyperledger/orderer/tls/server.crt
      - ORDERER_GENERAL_CLUSTER_CLIENTPRIVATEKEY=/var/hyperledger/orderer/tls/server.key
      - ORDERER_GENERAL_CLUSTER_ROOTCAS=[/var/hyperledger/orderer/tls/ca.crt]
      - ORDERER_GENERAL_BOOTSTRAPMETHOD=none
      - ORDERER_CHANNELPARTICIPATION_ENABLED=true
      - ORDERER_ADMIN_TLS_ENABLED=true
      - ORDERER_ADMIN_TLS_CERTIFICATE=/var/hyperledger/orderer/tls/server.crt
      - ORDERER_ADMIN_TLS_PRIVATEKEY=/var/hyperledger/orderer/tls/server.key
      - ORDERER_ADMIN_TLS_ROOTCAS=[/var/hyperledger/orderer/tls/ca.crt]
      - ORDERER_ADMIN_TLS_CLIENTROOTCAS=[/var/hyperledger/orderer/tls/ca.crt]
      - ORDERER_ADMIN_LISTENADDRESS=0.0.0.0:7053
      - ORDERER_OPERATIONS_LISTENADDRESS=0.0.0.0:9443
    command: orderer
    volumes:
      - ../organizations/ordererOrganizations/example.com/orderers/orderer.example.com/msp:/var/hyperledger/orderer/msp
      - ../organizations/ordererOrganizations/example.com/orderers/orderer.example.com/tls:/var/hyperledger/orderer/tls
      - orderer.example.com:/var/hyperledger/production/orderer
    ports:
      - 7050:7050
      - 7053:7053
      - 9443:9443
    networks:
      - fabric-did-network
    restart: unless-stopped
  # ─────────────────────────────────────────
  # PEER0
  # Stores blockchain, runs chaincode
  # Your API connects here
  # ─────────────────────────────────────────
  peer0.org1.example.com:
    image: hyperledger/fabric-peer:${FABRIC_IMAGE_TAG}
    container_name: peer0.org1.example.com
    environment:
      - FABRIC_CFG_PATH=/etc/hyperledger/peercfg
      - FABRIC_LOGGING_SPEC=INFO
      - CORE_PEER_TLS_ENABLED=true
      - CORE_PEER_TLS_CERT_FILE=/etc/hyperledger/fabric/tls/server.crt
      - CORE_PEER_TLS_KEY_FILE=/etc/hyperledger/fabric/tls/server.key
      - CORE_PEER_TLS_ROOTCERT_FILE=/etc/hyperledger/fabric/tls/ca.crt
      - CORE_PEER_ID=peer0.org1.example.com
      - CORE_PEER_ADDRESS=peer0.org1.example.com:7051
      - CORE_PEER_LISTENADDRESS=0.0.0.0:7051
      - CORE_PEER_CHAINCODEADDRESS=peer0.org1.example.com:7052
      - CORE_PEER_CHAINCODELISTENADDRESS=0.0.0.0:7052
      - CORE_PEER_GOSSIP_BOOTSTRAP=peer0.org1.example.com:7051
      - CORE_PEER_GOSSIP_EXTERNALENDPOINT=peer0.org1.example.com:7051
      - CORE_PEER_LOCALMSPID=Org1MSP
      - CORE_PEER_MSPCONFIGPATH=/etc/hyperledger/fabric/msp
      - CORE_LEDGER_STATE_STATEDATABASE=CouchDB
      - CORE_LEDGER_STATE_COUCHDBCONFIG_COUCHDBADDRESS=couchdb0:5984
      - CORE_LEDGER_STATE_COUCHDBCONFIG_USERNAME=${COUCHDB0_USER}
      - CORE_LEDGER_STATE_COUCHDBCONFIG_PASSWORD=${COUCHDB0_PASSWORD}
      - CORE_OPERATIONS_LISTENADDRESS=peer0.org1.example.com:9444
      - CORE_CHAINCODE_EXECUTETIMEOUT=300s
      - DOCKER_HOST=unix:///var/run/docker.sock
      - CORE_VM_ENDPOINT=unix:///var/run/docker.sock
      - DOCKER_BUILDKIT=0
    depends_on:
      - couchdb0
      - orderer.example.com
    command: peer node start
    volumes:
      - ../organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/msp:/etc/hyperledger/fabric/msp
      - ../organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls:/etc/hyperledger/fabric/tls
      - ../config:/etc/hyperledger/peercfg
      - peer0.org1.example.com:/var/hyperledger/production
      - ${DOCKER_SOCK_PATH}:/var/run/docker.sock
    ports:
      - 7051:7051
      - 9444:9444
    networks:
      - fabric-did-network
    restart: unless-stopped
  # ─────────────────────────────────────────
  # YOUR GO REST API
  # Built from your Dockerfile
  # Connects to peer0 to submit transactions
  # ─────────────────────────────────────────
  blockchain-api:
    container_name: blockchain-api
    build:
      context: ../blockchain-service
      dockerfile: Dockerfile
    environment:
      - FABRIC_MSP_ID=${ORG_MSP}
      - FABRIC_CHANNEL_NAME=${CHANNEL_NAME}
      - FABRIC_CHAINCODE_NAME=${CHAINCODE_NAME}
      - FABRIC_PEER_ENDPOINT=peer0.org1.example.com:7051
      - FABRIC_PEER_HOST_OVERRIDE=peer0.org1.example.com
      - FABRIC_CRYPTO_PATH=/app/crypto
      - SERVER_PORT=${API_PORT}
    volumes:
      - ../organizations/peerOrganizations/org1.example.com:/app/crypto
    ports:
      - "${API_PORT}:${API_PORT}"
    depends_on:
      - peer0.org1.example.com
    networks:
      - fabric-did-network
    restart: unless-stopped

Key differences from test network:

Feature Test Network Production Data storage Inside container (lost on restart) Named volumes (persists forever) Restart on crash No Yes — restart: unless-stopped Passwords Hardcoded adminpw From .env Image versions :latest (unpredictable) :2.5.15 (pinned) Number of files 3 compose files 1 combined file CouchDB No password Password protected

4.5 — config/core.yaml

Copy fabric-samples/config/core.yaml to config/core.yaml. Then pin the chaincode builder images so the peer uses correct Fabric 2.5.x images:

# Find and update these lines in core.yaml:
chaincode:
    builder: hyperledger/fabric-ccenv:2.5.15
    golang:
        runtime: hyperledger/fabric-baseos:2.5.15
    java:
        runtime: hyperledger/fabric-javaenv:2.5.15
    node:
        runtime: hyperledger/fabric-nodeenv:2.5.15

Also set network mode for Docker build compatibility:

vm:
    docker:
        hostConfig:
            NetworkMode: bridge

Why this matters: Without pinning, the peer looks for hyperledger/fabric-ccenv:$(TWO_DIGIT_VERSION) which can resolve to non-existent tags like 3.1, causing chaincode install to fail with "No such image."

4.6 — registerEnroll.sh (Replaces cryptogen)

This is the most important script. It replaces cryptogen entirely. Instead of generating all certificates at once from config files, it connects to the running Fabric CA containers and properly registers and enrolls each identity.

File: scripts/registerEnroll.sh

#!/usr/bin/env bash
# Load all passwords and settings from .env
source "$(dirname "$0")/../.env"
. "$(dirname "$0")/utils.sh"
# ─────────────────────────────────────────
# HELPER: Register identity if not already registered
# Makes this script safe to run multiple times
# ─────────────────────────────────────────
registerIfNotExists() {
  local IDENTITY_NAME=$1
  local IDENTITY_SECRET=$2
  local IDENTITY_TYPE=$3
  local CA_NAME=$4
  local TLS_CERT=$5
  fabric-ca-client register \
    --caname "$CA_NAME" \
    --id.name "$IDENTITY_NAME" \
    --id.secret "$IDENTITY_SECRET" \
    --id.type "$IDENTITY_TYPE" \
    --tls.certfiles "$TLS_CERT" 2>&1 | grep -v "Identity '$IDENTITY_NAME' is already registered" || true
}
# ─────────────────────────────────────────
# HELPER: Copy CA cert from running container
# The CA generates its own cert on first start
# We need it for TLS verification
# ─────────────────────────────────────────
ensureCaCerts() {
  local CONTAINER=$1
  local DEST=$2
  local MAX_TRIES=10
  local COUNT=0
  while [ $COUNT -lt $MAX_TRIES ]; do
    if docker cp "$CONTAINER:/etc/hyperledger/fabric-ca-server/ca-cert.pem" "$DEST" 2>/dev/null; then
      if openssl x509 -noout -in "$DEST" 2>/dev/null; then
        return 0
      fi
    fi
    COUNT=$((COUNT + 1))
    sleep 2
  done
  echo "ERROR: Could not get valid CA cert from $CONTAINER"
  exit 1
}
function createOrg1() {
  infoln "Enrolling the CA admin"
  mkdir -p organizations/peerOrganizations/org1.example.com/
  export FABRIC_CA_CLIENT_HOME=${PWD}/organizations/peerOrganizations/org1.example.com/
  set -x
  fabric-ca-client enroll \
    -u https://${CA_ORG1_ADMIN_USER}:${CA_ORG1_ADMIN_PASS}@localhost:${CA_ORG1_PORT} \
    --caname ca-org1 \
    --tls.certfiles "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  { set +x; } 2>/dev/null
  echo 'NodeOUs:
  Enable: true
  ClientOUIdentifier:
    Certificate: cacerts/localhost-7054-ca-org1.pem
    OrganizationalUnitIdentifier: client
  PeerOUIdentifier:
    Certificate: cacerts/localhost-7054-ca-org1.pem
    OrganizationalUnitIdentifier: peer
  AdminOUIdentifier:
    Certificate: cacerts/localhost-7054-ca-org1.pem
    OrganizationalUnitIdentifier: admin
  OrdererOUIdentifier:
    Certificate: cacerts/localhost-7054-ca-org1.pem
    OrganizationalUnitIdentifier: orderer' > "${PWD}/organizations/peerOrganizations/org1.example.com/msp/config.yaml"
  mkdir -p "${PWD}/organizations/peerOrganizations/org1.example.com/msp/tlscacerts"
  cp "${PWD}/organizations/fabric-ca/org1/ca-cert.pem" \
     "${PWD}/organizations/peerOrganizations/org1.example.com/msp/tlscacerts/ca.crt"
  mkdir -p "${PWD}/organizations/peerOrganizations/org1.example.com/tlsca"
  cp "${PWD}/organizations/fabric-ca/org1/ca-cert.pem" \
     "${PWD}/organizations/peerOrganizations/org1.example.com/tlsca/tlsca.org1.example.com-cert.pem"
  mkdir -p "${PWD}/organizations/peerOrganizations/org1.example.com/ca"
  cp "${PWD}/organizations/fabric-ca/org1/ca-cert.pem" \
     "${PWD}/organizations/peerOrganizations/org1.example.com/ca/ca.org1.example.com-cert.pem"
  infoln "Registering peer0"
  registerIfNotExists "peer0" "${PEER0_PW}" "peer" "ca-org1" \
    "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  infoln "Registering appUser"
  # NOTE: appUser NOT user1 — your Go API connects using this identity
  registerIfNotExists "appUser" "${APP_USER_PW}" "client" "ca-org1" \
    "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  infoln "Registering the org admin"
  registerIfNotExists "org1admin" "${ORG1_ADMIN_PW}" "admin" "ca-org1" \
    "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  infoln "Generating the peer0 msp"
  set -x
  fabric-ca-client enroll \
    -u https://peer0:${PEER0_PW}@localhost:${CA_ORG1_PORT} \
    --caname ca-org1 \
    -M "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/msp" \
    --tls.certfiles "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  { set +x; } 2>/dev/null
  cp "${PWD}/organizations/peerOrganizations/org1.example.com/msp/config.yaml" \
     "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/msp/config.yaml"
  infoln "Generating the peer0 TLS certificates"
  set -x
  fabric-ca-client enroll \
    -u https://peer0:${PEER0_PW}@localhost:${CA_ORG1_PORT} \
    --caname ca-org1 \
    -M "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls" \
    --enrollment.profile tls \
    --csr.hosts peer0.org1.example.com \
    --csr.hosts localhost \
    --tls.certfiles "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  { set +x; } 2>/dev/null
  # Copy TLS certs to well-known filenames (what peer container expects)
  cp "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls/tlscacerts/"* \
     "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls/ca.crt"
  cp "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls/signcerts/"* \
     "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls/server.crt"
  cp "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls/keystore/"* \
     "${PWD}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls/server.key"
  infoln "Generating the appUser msp"
  set -x
  fabric-ca-client enroll \
    -u https://appUser:${APP_USER_PW}@localhost:${CA_ORG1_PORT} \
    --caname ca-org1 \
    -M "${PWD}/organizations/peerOrganizations/org1.example.com/users/appUser/msp" \
    --tls.certfiles "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  { set +x; } 2>/dev/null
  cp "${PWD}/organizations/peerOrganizations/org1.example.com/msp/config.yaml" \
     "${PWD}/organizations/peerOrganizations/org1.example.com/users/appUser/msp/config.yaml"
  infoln "Generating the org admin msp"
  set -x
  fabric-ca-client enroll \
    -u https://org1admin:${ORG1_ADMIN_PW}@localhost:${CA_ORG1_PORT} \
    --caname ca-org1 \
    -M "${PWD}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp" \
    --tls.certfiles "${PWD}/organizations/fabric-ca/org1/ca-cert.pem"
  { set +x; } 2>/dev/null
  cp "${PWD}/organizations/peerOrganizations/org1.example.com/msp/config.yaml" \
     "${PWD}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp/config.yaml"
}
function createOrderer() {
  infoln "Enrolling the CA admin"
  mkdir -p organizations/ordererOrganizations/example.com
  export FABRIC_CA_CLIENT_HOME=${PWD}/organizations/ordererOrganizations/example.com
  set -x
  fabric-ca-client enroll \
    -u https://${CA_ORDERER_ADMIN_USER}:${CA_ORDERER_ADMIN_PASS}@localhost:${CA_ORDERER_PORT} \
    --caname ca-orderer \
    --tls.certfiles "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem"
  { set +x; } 2>/dev/null
  echo 'NodeOUs:
  Enable: true
  ClientOUIdentifier:
    Certificate: cacerts/localhost-9054-ca-orderer.pem
    OrganizationalUnitIdentifier: client
  PeerOUIdentifier:
    Certificate: cacerts/localhost-9054-ca-orderer.pem
    OrganizationalUnitIdentifier: peer
  AdminOUIdentifier:
    Certificate: cacerts/localhost-9054-ca-orderer.pem
    OrganizationalUnitIdentifier: admin
  OrdererOUIdentifier:
    Certificate: cacerts/localhost-9054-ca-orderer.pem
    OrganizationalUnitIdentifier: orderer' > "${PWD}/organizations/ordererOrganizations/example.com/msp/config.yaml"
  mkdir -p "${PWD}/organizations/ordererOrganizations/example.com/msp/tlscacerts"
  cp "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem" \
     "${PWD}/organizations/ordererOrganizations/example.com/msp/tlscacerts/tlsca.example.com-cert.pem"
  mkdir -p "${PWD}/organizations/ordererOrganizations/example.com/tlsca"
  cp "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem" \
     "${PWD}/organizations/ordererOrganizations/example.com/tlsca/tlsca.example.com-cert.pem"
  # Register and enroll only the orderer we have
  # To add more orderers later: for ORDERER in orderer orderer2 orderer3; do
  for ORDERER in orderer; do
    infoln "Registering ${ORDERER}"
    registerIfNotExists "${ORDERER}" "${ORDERER_PW}" "orderer" "ca-orderer" \
      "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem"
    infoln "Generating the ${ORDERER} MSP"
    set -x
    fabric-ca-client enroll \
      -u https://${ORDERER}:${ORDERER_PW}@localhost:${CA_ORDERER_PORT} \
      --caname ca-orderer \
      -M "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/msp" \
      --tls.certfiles "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem"
    { set +x; } 2>/dev/null
    cp "${PWD}/organizations/ordererOrganizations/example.com/msp/config.yaml" \
       "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/msp/config.yaml"
    mv "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/msp/signcerts/cert.pem" \
       "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/msp/signcerts/${ORDERER}.example.com-cert.pem"
    infoln "Generating the ${ORDERER} TLS certificates"
    set -x
    fabric-ca-client enroll \
      -u https://${ORDERER}:${ORDERER_PW}@localhost:${CA_ORDERER_PORT} \
      --caname ca-orderer \
      -M "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls" \
      --enrollment.profile tls \
      --csr.hosts ${ORDERER}.example.com \
      --csr.hosts localhost \
      --tls.certfiles "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem"
    { set +x; } 2>/dev/null
    cp "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls/tlscacerts/"* \
       "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls/ca.crt"
    cp "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls/signcerts/"* \
       "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls/server.crt"
    cp "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls/keystore/"* \
       "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls/server.key"
    mkdir -p "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/msp/tlscacerts"
    cp "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/tls/tlscacerts/"* \
       "${PWD}/organizations/ordererOrganizations/example.com/orderers/${ORDERER}.example.com/msp/tlscacerts/tlsca.example.com-cert.pem"
  done
  infoln "Registering the orderer admin"
  registerIfNotExists "ordererAdmin" "${ORDERER_ADMIN_PW}" "admin" "ca-orderer" \
    "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem"
  infoln "Generating the orderer admin msp"
  set -x
  fabric-ca-client enroll \
    -u https://ordererAdmin:${ORDERER_ADMIN_PW}@localhost:${CA_ORDERER_PORT} \
    --caname ca-orderer \
    -M "${PWD}/organizations/ordererOrganizations/example.com/users/Admin@example.com/msp" \
    --tls.certfiles "${PWD}/organizations/fabric-ca/ordererOrg/ca-cert.pem"
  { set +x; } 2>/dev/null
  cp "${PWD}/organizations/ordererOrganizations/example.com/msp/config.yaml" \
     "${PWD}/organizations/ordererOrganizations/example.com/users/Admin@example.com/msp/config.yaml"
}
# ─────────────────────────────────────────
# MAIN EXECUTION
# ─────────────────────────────────────────
cd "$(dirname "$0")/.."
infoln "Refreshing Org1 CA root cert from container..."
ensureCaCerts "ca_org1" "organizations/fabric-ca/org1/ca-cert.pem"
infoln "Refreshing Orderer CA root cert from container..."
ensureCaCerts "ca_orderer" "organizations/fabric-ca/ordererOrg/ca-cert.pem"
infoln "Creating Org1 identities..."
createOrg1
infoln "Creating Orderer identities..."
createOrderer
infoln "All identities created successfully!"

What changed from test-network:

Test Network Production user1 identity appUser identity (matches your Go API) adminpw hardcoded ${CA_ORG1_ADMIN_PASS} from .env Registers 4 orderers Registers 1 orderer createOrg2() function Deleted entirely Crashes if identity exists registerIfNotExists() makes it idempotent No CA cert check ensureCaCerts() waits and validates

4.7 — Go API Changes

Why These Files Need Changes

The test network API assumed it ran directly on your laptop, reading files from TEST_NETWORK_PATH. In production, the API runs inside a Docker container where paths are different and environment variables control all settings.

File: blockchain-service/config/config.go

Replace the entire file:

package config
import (
    "os"
    "path"
)
// getEnv reads an environment variable with a default fallback
func getEnv(key, defaultValue string) string {
    value := os.Getenv(key)
    if value == "" {
        return defaultValue
    }
    return value
}
var (
    // MSP ID must match configtx.yaml
    MSPID = getEnv("FABRIC_MSP_ID", "Org1MSP")
    // Channel name created by createChannel.sh
    ChannelName = getEnv("FABRIC_CHANNEL_NAME", "didchannel")
    // Chaincode name deployed by deployCC.sh
    ChaincodeName = getEnv("FABRIC_CHAINCODE_NAME", "basic")
    // Contract name inside your chaincode
    ContractName = getEnv("FABRIC_CONTRACT_NAME", "DIDContract")
    // Peer endpoint — use container name NOT localhost inside Docker
    // localhost inside a container means the container itself, not peer0
    PeerEndpoint = getEnv("FABRIC_PEER_ENDPOINT", "dns:///peer0.org1.example.com:7051")
    // Peer hostname for TLS certificate verification
    PeerHostOverride = getEnv("FABRIC_PEER_HOST_OVERRIDE", "peer0.org1.example.com")
    // API port
    Port = getEnv("SERVER_PORT", "8080")
    // Crypto path inside container
    // docker-compose mounts org1 crypto here: /app/crypto
    CryptoPath = getEnv("FABRIC_CRYPTO_PATH", "/app/crypto")
    // TLS certificate of peer0 — used to verify peer identity
    TLSCertPath = getEnv("FABRIC_TLS_CERT_PATH",
        path.Join(CryptoPath, "peers/peer0.org1.example.com/tls/ca.crt"))
    // appUser certificate — API uses this to sign transactions
    // NOTE: appUser not Admin — Admin is for network management only
    CertPath = getEnv("FABRIC_CERT_PATH",
        path.Join(CryptoPath, "users/appUser/msp/signcerts"))
    // appUser private key
    KeyPath = getEnv("FABRIC_KEY_PATH",
        path.Join(CryptoPath, "users/appUser/msp/keystore"))
)

File: blockchain-service/fabric/chaincodesdk.go

Remove the TestNetworkPath check that panics in production:

func Connect() (*client.Contract, *client.Gateway, *grpc.ClientConn) {
    // DELETE THESE LINES — TestNetworkPath no longer exists:
    // if config.TestNetworkPath == "" {
    //     panic("TEST_NETWORK_PATH env var is not set")
    // }
    conn := newGrpcConnection()
    // ... rest of function unchanged
}

File: blockchain-service/Dockerfile

Create this new file. The API did not have a Dockerfile — it ran directly on your laptop. Now it needs to run inside Docker alongside the Fabric containers.

# ═══════════════════════════════════════════
# STAGE 1 — BUILD
# Uses full Go compiler (~300MB)
# Compiles your API into a single binary
# ═══════════════════════════════════════════
FROM golang:1.21-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
# Copy dependency files first — Docker caches this layer
# If go.mod unchanged → skip re-downloading all modules
COPY go.mod go.sum ./
RUN go mod download
# Copy all source code
COPY . .
# Compile to Linux binary
# CGO_ENABLED=0 = static binary, no external C libraries
# GOOS=linux    = build for server even if you develop on Mac
# -ldflags "-w -s" = strip debug info, smaller binary
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -o blockchain-api \
    ./cmd/server/main.go
# ═══════════════════════════════════════════
# STAGE 2 — RUNTIME
# Tiny Alpine image (~15MB)
# Only contains the compiled binary
# No source code, no Go compiler
# ═══════════════════════════════════════════
FROM alpine:latest
# CA certificates needed for TLS connections to Fabric peers
RUN apk --no-cache add ca-certificates
# Create non-root user for security
# Running as root in containers is a security risk
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
# Copy only the compiled binary from stage 1
COPY --from=builder /app/blockchain-api .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
CMD ["./blockchain-api"]

4.8 — envVar.sh (Simplified)

File: scripts/envVar.sh

Replace the entire file. Remove Org2 and Org3, update paths:

#!/usr/bin/env bash
source "$(dirname "$0")/../.env"
. "$(dirname "$0")/utils.sh"
PRODUCTION_HOME="$(dirname "$0")/.."
export CORE_PEER_TLS_ENABLED=true
export ORDERER_CA=${PRODUCTION_HOME}/organizations/ordererOrganizations/example.com/tlsca/tlsca.example.com-cert.pem
export PEER0_ORG1_CA=${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com/tlsca/tlsca.org1.example.com-cert.pem
setGlobals() {
  local USING_ORG=""
  if [ -z "${OVERRIDE_ORG:-}" ]; then
    USING_ORG=$1
  else
    USING_ORG="${OVERRIDE_ORG}"
  fi
  infoln "Using organization ${USING_ORG}"
  if [ $USING_ORG -eq 1 ]; then
    export CORE_PEER_LOCALMSPID=Org1MSP
    export CORE_PEER_TLS_ROOTCERT_FILE=$PEER0_ORG1_CA
    export CORE_PEER_MSPCONFIGPATH=${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com/users/Admin@org1.example.com/msp
    export CORE_PEER_ADDRESS=localhost:7051
  else
    errorln "ORG Unknown"
  fi
  if [ "${VERBOSE:-false}" = "true" ]; then
    env | grep CORE
  fi
}
parsePeerConnectionParameters() {
  PEER_CONN_PARMS=()
  PEERS=""
  while [ "$#" -gt 0 ]; do
    setGlobals $1
    PEER="peer0.org$1"
    if [ -z "$PEERS" ]; then
      PEERS="$PEER"
    else
      PEERS="$PEERS $PEER"
    fi
    PEER_CONN_PARMS=("${PEER_CONN_PARMS[@]}" --peerAddresses $CORE_PEER_ADDRESS)
    CA=PEER0_ORG$1_CA
    TLSINFO=(--tlsRootCertFiles "${!CA}")
    PEER_CONN_PARMS=("${PEER_CONN_PARMS[@]}" "${TLSINFO[@]}")
    shift
  done
}
verifyResult() {
  if [ $1 -ne 0 ]; then
    fatalln "$2"
  fi
}

4.9 — createChannel.sh

File: scripts/createChannel.sh

Copy from test-network then make these changes:

#!/usr/bin/env bash
source "$(dirname "$0")/../.env"
. "$(dirname "$0")/envVar.sh"
DELAY="3"
MAX_RETRY="5"
VERBOSE="${VERBOSE:-false}"
: ${CONTAINER_CLI:="docker"}
if command -v ${CONTAINER_CLI}-compose > /dev/null 2>&1; then
    : ${CONTAINER_CLI_COMPOSE:="${CONTAINER_CLI}-compose"}
else
    : ${CONTAINER_CLI_COMPOSE:="${CONTAINER_CLI} compose"}
fi
if [ ! -d "channel-artifacts" ]; then
  mkdir channel-artifacts
fi
createChannelGenesisBlock() {
  # Point to configtx/ folder (not test-network/configtx)
  FABRIC_CFG_PATH="$(dirname "$0")/../configtx"
  which configtxgen
  if [ "$?" -ne 0 ]; then
    fatalln "configtxgen tool not found."
  fi
  set -x
  configtxgen \
    -profile ChannelUsingRaft \
    -outputBlock ./channel-artifacts/${CHANNEL_NAME}.block \
    -channelID ${CHANNEL_NAME}
  res=$?
  { set +x; } 2>/dev/null
  verifyResult $res "Failed to generate channel configuration block"
}
createChannel() {
  local rc=1
  local COUNTER=1
  infoln "Adding orderer to channel"
  while [ $rc -ne 0 -a $COUNTER -lt $MAX_RETRY ]; do
    sleep $DELAY
    # Execute (not source) orderer.sh so it runs isolated
    bash "$(dirname "$0")/orderer.sh" ${CHANNEL_NAME} > /dev/null 2>&1
    res=$?
    let rc=$res
    COUNTER=$(expr $COUNTER + 1)
  done
  cat log.txt
  verifyResult $res "Channel creation failed"
}
joinChannel() {
  ORG=$1
  FABRIC_CFG_PATH="$(dirname "$0")/../configtx"
  setGlobals $ORG
  local rc=1
  local COUNTER=1
  while [ $rc -ne 0 -a $COUNTER -lt $MAX_RETRY ]; do
    sleep $DELAY
    set -x
    peer channel join -b $BLOCKFILE >&log.txt
    res=$?
    { set +x; } 2>/dev/null
    let rc=$res
    COUNTER=$(expr $COUNTER + 1)
  done
  cat log.txt
  verifyResult $res "After $MAX_RETRY attempts, peer0.org${ORG} has failed to join channel '$CHANNEL_NAME'"
}
setAnchorPeer() {
  ORG=$1
  . "$(dirname "$0")/setAnchorPeer.sh" $ORG $CHANNEL_NAME
}
BLOCKFILE="./channel-artifacts/${CHANNEL_NAME}.block"
# Truncate log for clean output on re-runs
> log.txt
infoln "Generating channel genesis block '${CHANNEL_NAME}.block'"
createChannelGenesisBlock
infoln "Creating channel ${CHANNEL_NAME}"
createChannel
successln "Channel '${CHANNEL_NAME}' created"
infoln "Joining org1 peer to the channel..."
joinChannel 1
infoln "Setting anchor peer for org1..."
setAnchorPeer 1
successln "Channel '${CHANNEL_NAME}' joined successfully"

4.10 — deployCC.sh

File: scripts/deployCC.sh

Copy from test-network then make these changes:

#!/usr/bin/env bash
source "$(dirname "$0")/../.env"
source "$(dirname "$0")/utils.sh"
# Read ALL chaincode settings from .env
# No arguments needed — everything is configured
CHANNEL_NAME=${CHANNEL_NAME}
CC_NAME=${CHAINCODE_NAME}
CC_SRC_PATH=${CHAINCODE_PATH}
CC_SRC_LANGUAGE=${CHAINCODE_LANG}
CC_VERSION=${CHAINCODE_VERSION}
CC_SEQUENCE=${CHAINCODE_SEQUENCE}
CC_INIT_FCN="NA"
CC_END_POLICY="NA"
CC_COLL_CONFIG="NA"
DELAY="3"
MAX_RETRY="5"
VERBOSE="false"
INIT_REQUIRED=""
CC_END_POLICY=""
CC_COLL_CONFIG=""
# Point to configtx folder for core.yaml
FABRIC_CFG_PATH="$(dirname "$0")/../configtx"
. "$(dirname "$0")/envVar.sh"
. "$(dirname "$0")/ccutils.sh"
function checkPrereqs() {
  jq --version > /dev/null 2>&1
  if [[ $? -ne 0 ]]; then
    errorln "jq command not found. Install: brew install jq (Mac) or apt-get install jq (Linux)"
    exit 1
  fi
}
checkPrereqs
# Step 1: Package chaincode
"$(dirname "$0")/packageCC.sh" \
  $CC_NAME \
  $CC_SRC_PATH \
  $CC_SRC_LANGUAGE \
  $CC_VERSION
PACKAGE_ID=$(peer lifecycle chaincode calculatepackageid ${CC_NAME}.tar.gz)
# Step 2: Install on peer0
infoln "Installing chaincode on peer0.org1..."
installChaincode 1
# Step 3: Query installed
queryInstalled 1
# Step 4: Approve for Org1
approveForMyOrg 1
# Step 5: Check readiness
checkCommitReadiness 1 "\"Org1MSP\": true"
# Step 6: Commit (chaincode goes live)
commitChaincodeDefinition 1
# Step 7: Verify committed
queryCommitted 1
infoln "Chaincode deployed successfully."
exit 0

4.11 — backup.sh

File: scripts/backup.sh (new file — does not exist in test-network)

#!/usr/bin/env bash
set -e
source "$(dirname "$0")/../.env"
PRODUCTION_HOME="$(dirname "$0")/.."
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="${PRODUCTION_HOME}/backups/${TIMESTAMP}"
LOG_FILE="${PRODUCTION_HOME}/logs/backup.log"
mkdir -p "${BACKUP_DIR}"
mkdir -p "${PRODUCTION_HOME}/logs"
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}"
}
log "══════════════════════════════════════"
log " BACKUP STARTED: ${TIMESTAMP}"
log "══════════════════════════════════════"
# Backup 1: All certificates and private keys
log "→ Backing up crypto material..."
if [ -d "${PRODUCTION_HOME}/organizations" ]; then
  cp -r "${PRODUCTION_HOME}/organizations" "${BACKUP_DIR}/organizations"
  log " Crypto material backed up"
fi
# Backup 2: Channel artifacts
log "→ Backing up channel artifacts..."
if [ -d "${PRODUCTION_HOME}/channel-artifacts" ]; then
  cp -r "${PRODUCTION_HOME}/channel-artifacts" "${BACKUP_DIR}/channel-artifacts"
  log " Channel artifacts backed up"
fi
# Backup 3-5: Docker volumes (the actual blockchain data)
mkdir -p "${BACKUP_DIR}/volumes"
for VOLUME in orderer.example.com peer0.org1.example.com couchdb0_data; do
  FULL_VOLUME="${COMPOSE_PROJECT_NAME}_${VOLUME}"
  if docker volume inspect "${FULL_VOLUME}" > /dev/null 2>&1; then
    log "→ Backing up volume ${FULL_VOLUME}..."
    docker run --rm \
      -v "${FULL_VOLUME}:/data" \
      -v "${BACKUP_DIR}/volumes":/backup \
      alpine \
      tar czf "/backup/${VOLUME}.tar.gz" -C /data .
    log "${FULL_VOLUME} backed up"
  fi
done
# Cleanup old backups
log "→ Removing backups older than ${BACKUP_RETENTION_DAYS} days..."
find "${PRODUCTION_HOME}/backups" -maxdepth 1 -type d \
  -mtime +${BACKUP_RETENTION_DAYS} \
  -exec rm -rf {} \; 2>/dev/null || true
BACKUP_SIZE=$(du -sh "${BACKUP_DIR}" 2>/dev/null | cut -f1)
log "══════════════════════════════════════"
log " BACKUP COMPLETE — Size: ${BACKUP_SIZE}"
log " Location: ${BACKUP_DIR}"
log "══════════════════════════════════════"

4.12 — renewCerts.sh

File: scripts/renewCerts.sh (new file — does not exist in test-network)

#!/usr/bin/env bash
set -e
source "$(dirname "$0")/../.env"
PRODUCTION_HOME="$(dirname "$0")/.."
LOG_FILE="${PRODUCTION_HOME}/logs/renewcerts.log"
DAYS_BEFORE_EXPIRY=30
mkdir -p "${PRODUCTION_HOME}/logs"
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}"
}
log "══════════════════════════════════════"
log " CERT RENEWAL CHECK STARTED"
log "══════════════════════════════════════"
needsRenewal() {
  local CERT_FILE=$1
  local CERT_NAME=$2
  if [ ! -f "${CERT_FILE}" ]; then
    log "⚠️  Certificate not found: ${CERT_NAME}"
    return 1
  fi
  EXPIRY=$(openssl x509 -enddate -noout -in "${CERT_FILE}" | cut -d= -f2)
  if date -d "${EXPIRY}" > /dev/null 2>&1; then
    EXPIRY_EPOCH=$(date -d "${EXPIRY}" +%s)
  else
    EXPIRY_EPOCH=$(date -j -f "%b %d %T %Y %Z" "${EXPIRY}" +%s 2>/dev/null)
  fi
  NOW_EPOCH=$(date +%s)
  DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
  log "→ ${CERT_NAME}: ${DAYS_LEFT} days remaining"
  if [ "${DAYS_LEFT}" -lt "${DAYS_BEFORE_EXPIRY}" ]; then
    log "⚠️  Expiring soon → renewing"
    return 0
  fi
  log " ${CERT_NAME} is valid"
  return 1
}
RENEWED=false
# Renew peer0 TLS certificate
PEER0_TLS="${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls/server.crt"
if needsRenewal "${PEER0_TLS}" "peer0 TLS cert"; then
  export FABRIC_CA_CLIENT_HOME="${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com"
  fabric-ca-client enroll \
    -u https://peer0:${PEER0_PW}@localhost:${CA_ORG1_PORT} \
    --caname ca-org1 \
    -M "${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com/peers/peer0.org1.example.com/tls" \
    --enrollment.profile tls \
    --csr.hosts peer0.org1.example.com --csr.hosts localhost \
    --tls.certfiles "${PRODUCTION_HOME}/organizations/fabric-ca/org1/ca-cert.pem"
  docker restart peer0.org1.example.com
  RENEWED=true
  log " peer0 TLS cert renewed"
fi
# Renew orderer TLS certificate
ORDERER_TLS="${PRODUCTION_HOME}/organizations/ordererOrganizations/example.com/orderers/orderer.example.com/tls/server.crt"
if needsRenewal "${ORDERER_TLS}" "orderer TLS cert"; then
  export FABRIC_CA_CLIENT_HOME="${PRODUCTION_HOME}/organizations/ordererOrganizations/example.com"
  fabric-ca-client enroll \
    -u https://orderer:${ORDERER_PW}@localhost:${CA_ORDERER_PORT} \
    --caname ca-orderer \
    -M "${PRODUCTION_HOME}/organizations/ordererOrganizations/example.com/orderers/orderer.example.com/tls" \
    --enrollment.profile tls \
    --csr.hosts orderer.example.com --csr.hosts localhost \
    --tls.certfiles "${PRODUCTION_HOME}/organizations/fabric-ca/ordererOrg/ca-cert.pem"
  docker restart orderer.example.com
  RENEWED=true
  log " Orderer TLS cert renewed"
fi
# Renew appUser certificate (used by Go API)
APPUSER_CERT="${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com/users/appUser/msp/signcerts/cert.pem"
if needsRenewal "${APPUSER_CERT}" "appUser cert"; then
  export FABRIC_CA_CLIENT_HOME="${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com"
  fabric-ca-client enroll \
    -u https://appUser:${APP_USER_PW}@localhost:${CA_ORG1_PORT} \
    --caname ca-org1 \
    -M "${PRODUCTION_HOME}/organizations/peerOrganizations/org1.example.com/users/appUser/msp" \
    --tls.certfiles "${PRODUCTION_HOME}/organizations/fabric-ca/org1/ca-cert.pem"
  docker restart blockchain-api
  RENEWED=true
  log " appUser cert renewed"
fi
if [ "${RENEWED}" = false ]; then
  log " All certificates valid — no renewal needed"
fi
log "══════════════════════════════════════"
log " CERT RENEWAL CHECK COMPLETE"
log "══════════════════════════════════════"

4.13 — CI/CD Pipeline

File: .github/workflows/deploy.yml

name: Deploy to Server
on:
  push:
    branches: [main]
  workflow_dispatch:
jobs:
  deploy:
    # Runs on the self-hosted runner installed on your server
    # This is how we reach a private network server (192.168.x.x)
    runs-on: self-hosted
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Deploy
        run: |
          set -e
          DEPLOY_PATH="$HOME/did-production"
          FABRIC_BIN="$HOME/fabric-bin/bin"
          cd "$DEPLOY_PATH"
          # Pull latest code
          git fetch origin main
          git reset --hard origin/main
          # Verify .env exists (never committed)
          if [ ! -f .env ]; then
            echo "ERROR: .env not found on server. Create it manually."
            exit 1
          fi
          set -a
          source .env
          set +a
          export PATH="$FABRIC_BIN:$PATH"
          # Fix permissions so API container can read crypto files
          if [ -d organizations/peerOrganizations ]; then
            chmod -R a+rX organizations/peerOrganizations/
          fi
          # Rebuild and restart only the API
          # The Fabric network (orderer/peer/CouchDB) keeps running
          # Blockchain data is never touched by CI/CD
          docker compose -f compose/docker-compose.yaml --env-file .env \
            up -d --build blockchain-api
          sleep 5
          if docker ps --filter "name=blockchain-api" \
               --filter "status=running" \
               --format '{{.Names}}' | grep -q blockchain-api; then
            echo " blockchain-api is running."
          else
            echo "WARNING: blockchain-api may not be healthy."
            docker logs --tail 20 blockchain-api
          fi
          echo " Deploy complete."

Part 5 — Server Setup (One-Time)

Prerequisites

Your server needs Ubuntu 22.04+ and Docker installed.

Install Fabric Binaries on Server

The Mac has Mac binaries. The server needs Linux binaries. They are different binaries and cannot be swapped.

ssh your-user@your-server-ip
# Create binary directory
mkdir -p ~/fabric-bin
# Download Linux x86_64 binaries directly (no temp file to save space)
curl -sSL https://github.com/hyperledger/fabric/releases/download/v2.5.15/hyperledger-fabric-linux-amd64-2.5.15.tar.gz \
  | tar xz -C ~/fabric-bin
curl -sSL https://github.com/hyperledger/fabric-ca/releases/download/v1.5.15/hyperledger-fabric-ca-linux-amd64-1.5.15.tar.gz \
  | tar xz -C ~/fabric-bin
# Add to PATH permanently
echo 'export PATH="$HOME/fabric-bin/bin:$PATH"' >> ~/.bashrc
export PATH="$HOME/fabric-bin/bin:$PATH"
# Verify
peer version

Pull Docker Images

docker pull hyperledger/fabric-peer:2.5.15
docker pull hyperledger/fabric-orderer:2.5.15
docker pull hyperledger/fabric-ccenv:2.5.15
docker pull hyperledger/fabric-baseos:2.5.15
docker pull hyperledger/fabric-ca:1.5.15
docker pull couchdb:3.4.2

Clone and Configure

git clone https://github.com/YOUR_USER/did-production.git ~/did-production
cd ~/did-production
nano .env
# Fill in all passwords — same values as your local .env
# Set DOCKER_SOCK_PATH=/var/run/docker.sock

Start the Network

export PATH="$HOME/fabric-bin/bin:$PATH"
./network.sh clean
./network.sh up -g couchdb
./network.sh createChannel
./network.sh deployCC -c didchannel -ccn basic -ccp ./chaincode/chaincode-go -ccl go
docker compose -f compose/docker-compose.yaml --env-file .env up -d --build blockchain-api

Fix API Permissions

The API container runs as a non-root user (uid 1001). The crypto files generated by Fabric CA are owned by root. Fix this:

chmod -R a+rX ~/did-production/organizations/peerOrganizations/
docker restart blockchain-api

Test

curl -X POST http://YOUR_SERVER_IP:8080/api/users/register \
  -H "Content-Type: application/json" \
  -d '{"did":"did:example:user001","name":"Test User","email":"test@example.com"}'

Expected:

{"data":{"did":"did:example:user001"},"message":"User registered on blockchain","success":true}

Part 6 — Setting Up CI/CD

Why CI/CD Requires a Self-Hosted Runner

If your server is on a private network (like 192.168.x.x), GitHub's cloud servers cannot reach it. GitHub Actions cannot SSH to a private IP from the internet. The solution is to install a GitHub Actions runner on the server itself. The runner connects outbound to GitHub and waits for jobs. When you push, GitHub tells the runner to execute the workflow locally.

Your Mac                GitHub                  Your Server
─────────               ──────                  ───────────
git push  ──────────→  Receives push           Runner (background service)
                        Queues job   ──────→   Job executes locally
                                               git pull + docker build + restart

Install the Runner

  1. Go to [https://github.com/YOUR_USER/did-production/settings/actions/runners/new](https://github.com/YOUR_USER/did-production/settings/actions/runners/new)
  2. Select Linux, x64
  3. Follow the exact commands GitHub shows (they include a token unique to your repo)
ssh your-user@your-server-ip
mkdir -p ~/actions-runner && cd ~/actions-runner
curl -o actions-runner-linux-x64-2.332.0.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-x64-2.332.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.332.0.tar.gz
rm actions-runner-linux-x64-2.332.0.tar.gz
# Configure (use the token from GitHub's page)
./config.sh --url https://github.com/YOUR_USER/did-production --token YOUR_TOKEN_HERE
# Install as a system service — survives reboots
sudo ./svc.sh install
sudo ./svc.sh start

Test CI/CD

On your Mac:

git add .
git commit -m "Test CI/CD"
git push

Go to https://github.com/YOUR_USER/did-production/actions. You should see a green checkmark within about 30 seconds.

Part 7 — Daily Workflow

Once everything is running, this is how you work every day:

Action Where Edit chaincode or API code Mac git push Mac CI/CD rebuilds and restarts API Server (automatic) API calls Anywhere: curl http://SERVER_IP:8080/... Blockchain data Server — safe, persistent SSH to server Only for maintenance

You never SSH into the server for normal code changes. Just push.

Part 8 — Data Safety

What Persists

Docker named volumes store data on the server’s physical disk:

Volume What It Contains Survives Restart? fabric-did_peer0.org1.example.com All blockchain blocks Yes fabric-did_couchdb0_data Current DID records Yes fabric-did_orderer.example.com Orderer blockchain data Yes fabric-did_ca_org1_data CA identity database Yes

What Destroys Data

Only these commands delete blockchain data:

docker compose down --volumes   # Deletes everything
./network.sh clean              # Same — full wipe

Never run these on a live network unless you intend to start completely fresh.

Set Up Automatic Daily Backups

ssh your-user@your-server-ip
# Test backup manually first
cd ~/did-production
bash scripts/backup.sh
# Set up daily at 2am
(crontab -l 2>/dev/null; echo "0 2 * * * cd $HOME/did-production && bash scripts/backup.sh >> logs/backup.log 2>&1") | crontab -
# Verify cron is set
crontab -l

Set Up Monthly Certificate Renewal

(crontab -l 2>/dev/null; echo "0 3 1 * * cd $HOME/did-production && bash scripts/renewCerts.sh >> logs/renewcerts.log 2>&1") | crontab -

Part 9 — Common Issues and Fixes

“Exec format error” for peer or fabric-ca-client

You copied Mac binaries to a Linux server. They are different architectures. Install Linux binaries on the server following Part 5.

“No such image: hyperledger/fabric-ccenv:2.5.15”

The peer needs this image to build chaincode. Pull it:

docker pull hyperledger/fabric-ccenv:2.5.15
docker pull hyperledger/fabric-baseos:2.5.15

“permission denied” reading crypto files

The API container user cannot read the crypto files:

chmod -R a+rX ~/did-production/organizations/peerOrganizations/
docker restart blockchain-api

“Identity already registered” (Error Code 74)

Not an error. The registerIfNotExists() helper in registerEnroll.sh handles this. Registration is idempotent — safe to run multiple times.

“ENDORSEMENT_POLICY_FAILURE” during chaincode commit

The Endorsement policy is missing from configtx.yaml. Add it under Org1's Policies section (see Part 4.3, Change 2). Then ./network.sh clean and recreate the channel.

“broken pipe” during chaincode install on Mac Docker Desktop

Docker Desktop for Mac routes container-to-daemon builds through an internal proxy that sometimes fails. Solutions:

  1. Set DOCKER_BUILDKIT=0 in compose environment for the peer
  2. Pull fabric-ccenv and fabric-baseos images before deploying
  3. Use Colima instead of Docker Desktop: brew install colima && colima start --cpu 4 --memory 8

No space left on device on server

docker system prune -a --volumes -f
sudo apt-get clean
sudo journalctl --vacuum-size=20M
sudo snap remove --purge lxd   # if installed

Minimum recommended disk space: 20GB.

What You Built

Let’s review the complete transformation:

Feature Test Network Production Certificate generation cryptogen (expires, cannot renew) Fabric CA (renews, dynamic) Passwords Hardcoded everywhere Single .env file Data storage Inside containers (lost on restart) Named Docker volumes Auto-restart No restart: unless-stopped Backups None Daily automated backup Certificate renewal Impossible Monthly automated renewal CI/CD None git push → auto-deploy Docker compose files 3 separate files 1 combined file User identity user1 appUser (matches API) API config Hardcoded paths Environment variables API deployment Runs on laptop Docker container docker compose down Wipes data Safe (named volumes)

Next Steps

This guide covered a single organization with one peer and one orderer. From here you can:

Add a second peer — update the orderer loop in registerEnroll.sh to register peer1, add a second peer container to docker-compose.yaml, and join it to the channel.

Add more orderers for fault tolerance — uncomment orderer2 and orderer3 in the orderer loop, add them to docker-compose.yaml, and update the EtcdRaft.Consenters section in configtx.yaml.

Add monitoring — add Prometheus and Grafana containers to docker-compose.yaml. Both Fabric peer and orderer already expose metrics on ports 9444 and 9443.


메타데이터
post_id
148bce770e16
slug
how-to-convert-your-fabric-test-network-into-a-production-ready-blockchain-with-ci-cd-148bce770e16
url
https://coinsbench.com/how-to-convert-your-fabric-test-network-into-a-production-ready-blockchain-with-ci-cd-148bce770e16
canonical_url
https://coinsbench.com/how-to-convert-your-fabric-test-network-into-a-production-ready-blockchain-with-ci-cd-148bce770e16
author_url
https://medium.com/@muhammadtalha1
status
ok
fetched_at
2026-06-09 15:37:30