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
Architecture:
┌─────────────────┐
│ Source Code │
│ GitHub │
└────────┬────────┘
│
Git versioning
│
┌────────────┴────────────┐
│ │
▼ ▼
GitHub Release Zenodo
v3 DOI
│
│
▼
GitHub Actions
│
Docker
│
▼
GitHub GHCR
│
▼
docker pull
│
▼
Reproducible app
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
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
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
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
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/...
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..."
you provide:
Docker image
↓
docker run
↓
application
7. GitHub Actions
GitHub Actions is the automation layer.
Instead of manually doing:
build Docker image
↓
test
↓
push image
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 it:
cd YOUR_REPOSITORY
Check:
git status
You should see:
On branch main
Your branch is up to date with 'origin/main'.
Key insight
origin is simply the conventional name for your remote GitHub repository.
Check it:
git remote -v
You might see:
origin git@github.com:username/project.git (fetch)
origin git@github.com:username/project.git (push)
So:
local repository
│
│ origin
▼
GitHub repository
Part III — Establish a release
Suppose your current project state should become version 3.
First inspect existing tags:
git tag
For example:
v1
v2
Make sure your working tree is clean:
git status
Commit your current changes:
git add .
git commit -m "Prepare v3 release"
Push:
git push origin main
Now create the tag:
git tag v3
Push the tag:
git push origin v3
Verify remote tags:
git ls-remote --tags origin
You should see:
refs/tags/v1
refs/tags/v2
refs/tags/v3
⭐ Important lesson: tag ≠ GitHub Release
At this point:
v3
is a Git tag.
It isn't necessarily a GitHub Release yet.
Go to:
GitHub → Releases → Draft a new release
Select:
v3
Give it a title:
Schedule App v3
Add release notes and publish.
Now you have:
Git commit
↓
Git tag v3
↓
GitHub Release v3
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
Your resulting DOI might look like:
10.5281/zenodo.xxxxxxx
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:
[](https://doi.org/10.5281/zenodo.22981870)
For example:
# Schedule App
[](https://doi.org/10.5281/zenodo.22981870)
An interactive scheduling tool that models people's preferred meeting
times using Gaussian distributions.
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
Then:
docker info
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;
Why?
Normally Next.js produces a .next build directory containing various things.
With:
output: "standalone"
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
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"]
🧠 Understand the Dockerfile
This is a multi-stage build.
Stage 1 — dependencies
package.json
package-lock.json
↓
npm ci
↓
node_modules
Stage 2 — builder
source code
+
node_modules
↓
npm run build
↓
.next/standalone
Stage 3 — runner
Only production artifacts are copied:
standalone
static
public
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
Put:
node_modules
.next
.git
.github
.vscode
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
Dockerfile
.dockerignore
Why?
Docker sends a build context to the builder.
You don't want:
.git
node_modules
.next
unnecessarily included.
Part VII — Build locally
From the repository root:
docker build -t schedule-app:local .
The final:
.
means:
Use the current directory as the Docker build context.
Check the image:
docker images schedule-app
You should see:
schedule-app:local
Part VIII — Run the container
docker run --rm -p 3000:3000 schedule-app:local
Then visit:
http://localhost:3000
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
You ran:
docker run ...
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
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
Understand what this workflow does
The important trigger is:
on:
push:
tags:
- "v*"
Meaning:
When a version tag is pushed, run this workflow.
Then:
Git tag
↓
GitHub Actions
↓
Checkout source
↓
Build Docker image
↓
Push image
↓
GHCR
Part X — GitHub Container Registry
GHCR is:
GitHub Container Registry
Your image gets a name like:
ghcr.io/galaxyeagle/schedule_app
Think of it as:
GitHub
│
├── Repository
│ └── source code
│
└── Container Registry
└── Docker images
They're related but different artifacts.
Part XI — Verify the published image
After GitHub Actions succeeds:
docker pull ghcr.io/galaxyeagle/schedule_app:latest
Then:
docker run --rm -p 3000:3000 \
ghcr.io/galaxyeagle/schedule_app:latest
Visit:
http://localhost:3000
If it works, you've demonstrated something very important:
Source code
↓
GitHub
↓
Automated build
↓
Container registry
↓
Fresh machine
↓
docker pull
↓
Application runs
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
And potentially:
v3
│
▼
Docker image
│
:v3 / :latest
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
Then somebody can reproduce exactly what you used.
Part XIII — Why latest isn't enough
Suppose you only publish:
schedule_app:latest
Today:
latest → v3
Six months later:
latest → v4
Someone trying to reproduce your v3 research gets the wrong software.
That's why versioned Docker tags matter:
schedule_app:v3
is much more reproducible than:
schedule_app:latest
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
A researcher can discover the software and cite it.
A — Accessible
GitHub source
+
Zenodo archived release
+
Docker image
Multiple access paths exist.
I — Interoperable
You aren't inventing proprietary packaging.
You're using:
Git
Markdown
JSON/YAML
Docker
OCI container ecosystem
DOI
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?
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
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
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
Top comments (0)