DEV Community

Raman Butta
Raman Butta

Posted on Edited on

FAIR Research Software with GitHub, GitHub Actions, Docker & Zenodo

Here I will explain how to take a small research/software project and make it:

  • Findable → Zenodo + DOI + metadata
  • Accessible → GitHub source + GitHub Releases
  • Interoperable → standard Git/Docker artifacts
  • Reusable → documented code + reproducible container

This is a practical introduction to the FAIR principles through an actual Next.js application.

Our example:

Schedule App
Enter fullscreen mode Exit fullscreen mode

Architecture:

                 ┌─────────────────┐
                 │   Source Code   │
                 │     GitHub      │
                 └────────┬────────┘
                          │
                    Git versioning
                          │
             ┌────────────┴────────────┐
             │                         │
             ▼                         ▼
       GitHub Release              Zenodo
          v3                       DOI
             │
             │
             ▼
       GitHub Actions
             │
          Docker
             │
             ▼
       GitHub GHCR
             │
             ▼
        docker pull
             │
             ▼
       Reproducible app
Enter fullscreen mode Exit fullscreen mode

Part I — Understand the objects first

Before touching the terminal, understand the vocabulary.

1. Git

Git tracks changes to source code.

commit A
   ↓
commit B
   ↓
commit C
Enter fullscreen mode Exit fullscreen mode

A commit answers:

What exactly was the state of the code at this point?


2. GitHub

GitHub hosts the Git repository remotely.

Local Git repository
        │
        │ push
        ▼
GitHub repository
Enter fullscreen mode Exit fullscreen mode

GitHub gives you:

  • source-code hosting
  • collaboration
  • Issues
  • Actions
  • Releases
  • Packages

3. Git tag

A tag is a named pointer to a specific commit.

For example:

commit 540886d
      ↑
     v3
Enter fullscreen mode Exit fullscreen mode

It says:

"Call this particular state of the source code v3."


4. GitHub Release

A GitHub Release is a human-facing release record associated with a Git tag.

Think:

Git tag
  ↓
GitHub Release
  ↓
release notes + downloadable artifacts
Enter fullscreen mode Exit fullscreen mode

A tag can exist without a Release.


5. Zenodo

Zenodo is an archival/research repository.

It gives your software a persistent scholarly identity:

Schedule App
     │
     ▼
Zenodo
     │
     ▼
DOI
10.5281/zenodo/...
Enter fullscreen mode Exit fullscreen mode

The DOI makes the software citable and persistently identifiable.


6. Docker

Docker packages the runtime environment with your application.

Instead of:

"Install Node 22,
install these packages,
configure this,
hope it works..."
Enter fullscreen mode Exit fullscreen mode

you provide:

Docker image
     ↓
docker run
     ↓
application
Enter fullscreen mode Exit fullscreen mode

7. GitHub Actions

GitHub Actions is the automation layer.

Instead of manually doing:

build Docker image
        ↓
test
        ↓
push image
Enter fullscreen mode Exit fullscreen mode

you tell GitHub:

"Whenever I create a release/tag, do these steps automatically."


Part II — Start with an existing GitHub repository

Suppose your project already exists on GitHub.

Clone it:

git clone git@github.com:YOUR_USERNAME/YOUR_REPOSITORY.git
Enter fullscreen mode Exit fullscreen mode

Enter it:

cd YOUR_REPOSITORY
Enter fullscreen mode Exit fullscreen mode

Check:

git status
Enter fullscreen mode Exit fullscreen mode

You should see:

On branch main
Your branch is up to date with 'origin/main'.
Enter fullscreen mode Exit fullscreen mode

Key insight

origin is simply the conventional name for your remote GitHub repository.

Check it:

git remote -v
Enter fullscreen mode Exit fullscreen mode

You might see:

origin  git@github.com:username/project.git (fetch)
origin  git@github.com:username/project.git (push)
Enter fullscreen mode Exit fullscreen mode

So:

local repository
       │
       │ origin
       ▼
GitHub repository
Enter fullscreen mode Exit fullscreen mode

Part III — Establish a release

Suppose your current project state should become version 3.

First inspect existing tags:

git tag
Enter fullscreen mode Exit fullscreen mode

For example:

v1
v2
Enter fullscreen mode Exit fullscreen mode

Make sure your working tree is clean:

git status
Enter fullscreen mode Exit fullscreen mode

Commit your current changes:

git add .
git commit -m "Prepare v3 release"
Enter fullscreen mode Exit fullscreen mode

Push:

git push origin main
Enter fullscreen mode Exit fullscreen mode

Now create the tag:

git tag v3
Enter fullscreen mode Exit fullscreen mode

Push the tag:

git push origin v3
Enter fullscreen mode Exit fullscreen mode

Verify remote tags:

git ls-remote --tags origin
Enter fullscreen mode Exit fullscreen mode

You should see:

refs/tags/v1
refs/tags/v2
refs/tags/v3
Enter fullscreen mode Exit fullscreen mode

⭐ Important lesson: tag ≠ GitHub Release

At this point:

v3
Enter fullscreen mode Exit fullscreen mode

is a Git tag.

It isn't necessarily a GitHub Release yet.

Go to:

GitHub → Releases → Draft a new release

Select:

v3
Enter fullscreen mode Exit fullscreen mode

Give it a title:

Schedule App v3
Enter fullscreen mode Exit fullscreen mode

Add release notes and publish.

Now you have:

Git commit
    ↓
Git tag v3
    ↓
GitHub Release v3
Enter fullscreen mode Exit fullscreen mode

This distinction is extremely useful.


Part IV — Zenodo

Now connect the repository to Zenodo.

Zenodo can archive a GitHub release and create a DOI.

Conceptually:

GitHub Release v3
       │
       ▼
     Zenodo
       │
       ▼
 DOI
Enter fullscreen mode Exit fullscreen mode

Your resulting DOI might look like:

10.5281/zenodo.xxxxxxx
Enter fullscreen mode Exit fullscreen mode

The workflow is :

Connect your GitHub repository to Zenodo:

  • Open your repository. For the standard GitHub integration, make sure the repository is public and contains a suitable software license.
  • Open Zenodo and sign in using the GitHub account that owns or can access the repository.
  • If prompted, connect your GitHub account and authorize Zenodo. Review the permissions requested before approving.
  • Go to Zenodo GitHub settings. Click Sync now if necessary, find your repo and switch its toggle to ON. Refresh the page to verify that it remains enabled.

You have connected the repository, but you have not necessarily received a DOI yet. Zenodo needs to process a qualifying GitHub release.

To create a GitHub release to generate your first DOI,

  • open your repository on GitHub and select Releases, click Draft a new release.
  • Then Choose or create a version tag, such as v1.0.0.

(If you have already published releases such as v1, v2 and v3 before enabling Zenodo, create a new release after enabling the integration. Earlier releases are not necessarily archived retroactively.)

  • Enter a release title, such as v1.0.0 — Initial research release, and write a short description of the changes.
  • Click Publish release.
  • Return to Zenodo and wait for the release to be processed. Open the resulting record and check that the archived version has a DOI. Processing time can vary.

You should now have a Zenodo record containing the archived source code, release metadata and a DOI.

FAIR principle

This contributes strongly to:

F — Findable

because your software gets:

  • persistent metadata
  • DOI
  • searchable scholarly record
  • citation information

It also contributes to:

A — Accessible

because people have a persistent location from which to access the archived release.


Part V — Add the DOI to README

A good README should expose the DOI prominently.

Use the Zenodo badge:

[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22981870.svg)](https://doi.org/10.5281/zenodo.22981870)
Enter fullscreen mode Exit fullscreen mode

For example:

# Schedule App

[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22981870.svg)](https://doi.org/10.5281/zenodo.22981870)

An interactive scheduling tool that models people's preferred meeting
times using Gaussian distributions.
Enter fullscreen mode Exit fullscreen mode

Now your GitHub repository immediately communicates:

This isn't just some random code repository. This software has a citable archival record.


Part VI — Containerize the application

Now we introduce Docker.

Step 1 — Check Docker

docker --version
Enter fullscreen mode Exit fullscreen mode

Then:

docker info
Enter fullscreen mode Exit fullscreen mode

The second command verifies that the Docker daemon is actually accessible.


Step 2 — Configure Next.js

For Next.js, use standalone output.

next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Why?

Normally Next.js produces a .next build directory containing various things.

With:

output: "standalone"
Enter fullscreen mode Exit fullscreen mode

Next.js creates a self-contained production server suitable for copying into a minimal Docker image.


Step 3 — Create Dockerfile

At the repository root:

Dockerfile
Enter fullscreen mode Exit fullscreen mode

Use a multi-stage build:

# syntax=docker/dockerfile:1

FROM node:22-alpine AS base

# Install dependencies
FROM base AS deps

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm ci

# Build application
FROM base AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

# Production image
FROM base AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

RUN addgroup --system --gid 1001 nodejs \
    && adduser --system --uid 1001 nextjs

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

USER nextjs

EXPOSE 3000

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

🧠 Understand the Dockerfile

This is a multi-stage build.

Stage 1 — dependencies

package.json
package-lock.json
       ↓
     npm ci
       ↓
node_modules
Enter fullscreen mode Exit fullscreen mode

Stage 2 — builder

source code
    +
node_modules
    ↓
npm run build
    ↓
.next/standalone
Enter fullscreen mode Exit fullscreen mode

Stage 3 — runner

Only production artifacts are copied:

standalone
static
public
Enter fullscreen mode Exit fullscreen mode

The final image doesn't need your entire development environment.

This is why multi-stage builds are common in production.


Step 4 — .dockerignore

Create:

.dockerignore
Enter fullscreen mode Exit fullscreen mode

Put:

node_modules
.next
.git
.github
.vscode

npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

Dockerfile
.dockerignore
Enter fullscreen mode Exit fullscreen mode

Why?

Docker sends a build context to the builder.

You don't want:

.git
node_modules
.next
Enter fullscreen mode Exit fullscreen mode

unnecessarily included.


Part VII — Build locally

From the repository root:

docker build -t schedule-app:local .
Enter fullscreen mode Exit fullscreen mode

The final:

.
Enter fullscreen mode Exit fullscreen mode

means:

Use the current directory as the Docker build context.

Check the image:

docker images schedule-app
Enter fullscreen mode Exit fullscreen mode

You should see:

schedule-app:local
Enter fullscreen mode Exit fullscreen mode

Part VIII — Run the container

docker run --rm -p 3000:3000 schedule-app:local
Enter fullscreen mode Exit fullscreen mode

Then visit:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

If your application appears:

🎉 You have proven that the application can run independently of your local Node development environment.


⭐ The important reproducibility insight

Notice what happened.

You didn't run:

npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

You ran:

docker run ...
Enter fullscreen mode Exit fullscreen mode

The container contains the runtime environment required to run the application.

That's the beginning of reproducibility.


Part IX — Put the Docker process into GitHub Actions

Now automate it.

Create:

.github/
└── workflows/
    └── docker.yml
Enter fullscreen mode Exit fullscreen mode

A simple release-triggered workflow:

name: Docker Image

on:
  push:
    tags:
      - "v*"

  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  docker:
    name: Build and publish Docker image
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

Understand what this workflow does

The important trigger is:

on:
  push:
    tags:
      - "v*"
Enter fullscreen mode Exit fullscreen mode

Meaning:

When a version tag is pushed, run this workflow.

Then:

Git tag
   ↓
GitHub Actions
   ↓
Checkout source
   ↓
Build Docker image
   ↓
Push image
   ↓
GHCR
Enter fullscreen mode Exit fullscreen mode

Part X — GitHub Container Registry

GHCR is:

GitHub Container Registry

Your image gets a name like:

ghcr.io/galaxyeagle/schedule_app
Enter fullscreen mode Exit fullscreen mode

Think of it as:

GitHub
│
├── Repository
│      └── source code
│
└── Container Registry
       └── Docker images
Enter fullscreen mode Exit fullscreen mode

They're related but different artifacts.


Part XI — Verify the published image

After GitHub Actions succeeds:

docker pull ghcr.io/galaxyeagle/schedule_app:latest
Enter fullscreen mode Exit fullscreen mode

Then:

docker run --rm -p 3000:3000 \
  ghcr.io/galaxyeagle/schedule_app:latest
Enter fullscreen mode Exit fullscreen mode

Visit:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

If it works, you've demonstrated something very important:

Source code
     ↓
GitHub
     ↓
Automated build
     ↓
Container registry
     ↓
Fresh machine
     ↓
docker pull
     ↓
Application runs
Enter fullscreen mode Exit fullscreen mode

That is much stronger evidence of reproducibility than simply saying:

"It works on my computer."


Part XII — The versioning lesson

There are multiple versioned objects:

                 v3
                  │
       ┌──────────┼──────────┐
       │          │          │
       ▼          ▼          ▼
     Git tag   GitHub      Zenodo
                Release      DOI
Enter fullscreen mode Exit fullscreen mode

And potentially:

                  v3
                   │
                   ▼
              Docker image
                   │
              :v3 / :latest
Enter fullscreen mode Exit fullscreen mode

These should be intentionally aligned, but they aren't automatically the same thing.

For a polished research software project, you ideally want:

Software v3
│
├── Git commit
├── Git tag: v3
├── GitHub Release: v3
├── Zenodo archive: v3
├── DOI
└── Docker image: v3
Enter fullscreen mode Exit fullscreen mode

Then somebody can reproduce exactly what you used.


Part XIII — Why latest isn't enough

Suppose you only publish:

schedule_app:latest
Enter fullscreen mode Exit fullscreen mode

Today:

latest → v3
Enter fullscreen mode Exit fullscreen mode

Six months later:

latest → v4
Enter fullscreen mode Exit fullscreen mode

Someone trying to reproduce your v3 research gets the wrong software.

That's why versioned Docker tags matter:

schedule_app:v3
Enter fullscreen mode Exit fullscreen mode

is much more reproducible than:

schedule_app:latest
Enter fullscreen mode Exit fullscreen mode

latest is convenient.

A version tag is archival.


Part XIV — FAIR mapping

Now we can connect the technical exercise to FAIR.

FAIR principle What your project does
Findable GitHub repository + Zenodo metadata + DOI
Accessible Public GitHub repository + Zenodo archive
Interoperable Git, Markdown, Docker, standard package formats
Reusable README + version history + DOI + Dockerized runtime

More specifically

F — Findable

GitHub
+
Zenodo
+
DOI
+
metadata
Enter fullscreen mode Exit fullscreen mode

A researcher can discover the software and cite it.


A — Accessible

GitHub source
+
Zenodo archived release
+
Docker image
Enter fullscreen mode Exit fullscreen mode

Multiple access paths exist.


I — Interoperable

You aren't inventing proprietary packaging.

You're using:

Git
Markdown
JSON/YAML
Docker
OCI container ecosystem
DOI
Enter fullscreen mode Exit fullscreen mode

These are widely understood standards.


R — Reusable

This is where your documentation becomes important.

Someone needs to understand:

What does this software do?
How do I install it?
Which version am I using?
How do I run it?
Where is the archived version?
How do I cite it?
Enter fullscreen mode Exit fullscreen mode

Your README + DOI + release + Docker image answer those questions.


Part XV — The complete mental model

                       RESEARCH SOFTWARE
                              │
                              ▼
                         Git repository
                              │
                    ┌─────────┴─────────┐
                    │                   │
                 commits              tags
                    │                   │
                    │                  v3
                    │                   │
                    │          ┌────────┴────────┐
                    │          │                 │
                    │          ▼                 ▼
                    │     GitHub Release       Zenodo
                    │          v3                │
                    │                            ▼
                    │                           DOI
                    │
                    ▼
              GitHub Actions
                    │
                    ▼
               Docker Build
                    │
                    ▼
                  GHCR
                    │
                    ▼
             schedule_app:v3
                    │
                    ▼
              docker pull
                    │
                    ▼
              Reproducible
               execution
Enter fullscreen mode Exit fullscreen mode

That is essentially the FAIR research-software lifecycle we just practiced.


Conclusion

This wasn't just a Docker tutorial.

We discussed several layers of modern research software engineering:

Level 1
Git
│
└── version control

Level 2
GitHub
│
└── remote collaboration/distribution

Level 3
GitHub Releases
│
└── software versioning

Level 4
Zenodo
│
└── scholarly archival + DOI

Level 5
Docker
│
└── environment reproducibility

Level 6
GHCR
│
└── distribution of executable artifacts

Level 7
GitHub Actions
│
└── automation / CI/CD
Enter fullscreen mode Exit fullscreen mode

And the really important conceptual leap is:

A research software artifact isn't just source code.

It can have:

Source
+
Version
+
Metadata
+
Citation
+
Archive
+
Runtime environment
+
Automated build
Enter fullscreen mode Exit fullscreen mode

Top comments (0)