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…

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
- Go to
[https://github.com/YOUR_USER/did-production/settings/actions/runners/new](https://github.com/YOUR_USER/did-production/settings/actions/runners/new) - Select Linux, x64
- 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:
- Set
DOCKER_BUILDKIT=0in compose environment for the peer - Pull
fabric-ccenvandfabric-baseosimages before deploying - 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