BashTeacher.com
Course: II1305 - Project in Information and Communication Technology, link
BashTeacher.com is a website where you learn Bash by playing through interactive lessons in a real terminal. It was built together with a few other students during the spring 2026 term, in four one-week Scrum sprints between April and May. The project ended with a public demo of the finished product. Everything was built from scratch. The frontend is React, and the backend is a Node.js server that manages the database and spins up an isolated Docker container for every player.
Lobby
After logging in, players land in a lobby showing their lesson progress as a winding path. Each node is one lesson: completed lessons are green, the current one is highlighted with “YOU”, and lessons further ahead stay locked until the previous ones are done.
Stack
- React + TypeScript, built with Vite
- xterm.js for the in-browser terminal
- Socket.io client for the terminal connection
- Node.js + Express, TypeScript
- PostgreSQL for users, lessons and progress
- GitHub OAuth login (Passport)
- Docker, spawned per player through node-pty
Per-session sandboxed terminal
Each player gets a real terminal in the browser, connected over a WebSocket to the backend. When a player connects, the backend spawns a brand new Docker container just for that connection using node-pty, so keystrokes and output stream both ways like a real SSH session:
const containerName = `sandbox-${socket.id}`;
const ptyProcess = pty.spawn('docker', [
'run',
'--name', containerName,
'--rm',
'--network', 'none',
'--memory', '256m',
'--memory-swap', '256m',
'--pids-limit', '64',
'--cpus', '1',
'-it', imageName
], {
name: 'xterm-256color',
cols: 80,
rows: 24,
});
ptyProcess.onData((data) => socket.emit('output', data));
socket.on('input', (data) => ptyProcess.write(data));
socket.on('disconnect', () => {
ptyProcess.kill();
exec(`docker rm -f ${containerName}`);
});
Since players can run any Bash command they want, the container needed to be locked down. --network none means it can’t reach the internet or other containers. The memory and --pids-limit flags stop a mistake like a fork bomb from taking down the server. --rm, together with an explicit docker rm -f on disconnect, makes sure containers never pile up.
Visualizing the filesystem
Next to the terminal is a separate file-tree panel showing the container’s real filesystem, but folders start out unexplored until the player actually visits them. This was meant to teach that the filesystem is something you navigate, not something you can see all at once.
After every Enter keypress, the backend lists the directories and files inside the container over docker exec, and combines that with a discovered-paths file that a small Go program inside the container keeps updated. Each path is then marked as known, unknown, or hidden:
const discoveredChildren = discoveredMap[parentAbsPath] || [];
const isDiscovered = discoveredChildren.includes(part);
let childStatus: 'known' | 'unknown' | 'hidden';
if (part.startsWith('.')) {
childStatus = isDiscovered ? 'hidden' : 'unknown';
} else {
childStatus = isDiscovered ? 'known' : 'unknown';
}
The frontend renders that tree recursively, one node at a time. Each node picks its icon based on its status, and only renders a <ul> of children if the status isn’t unknown. That way, even if the backend already sent the full subtree, an unexplored folder never gets its children drawn:
function renderNode(node: FileNode) {
let iconSrc = iconFile;
if (node.isDirectory) {
switch (node.status) {
case 'unknown':
iconSrc = iconUnknownClosed;
break;
case 'known':
iconSrc = node.isOpen ? iconKnownOpen : iconKnownClosed;
break;
case 'hidden':
iconSrc = node.isOpen ? iconHiddenOpen : iconHiddenClosed;
break;
}
}
return (
<li key={node.nodeKey}>
<img src={iconSrc} alt="icon" />
<span>{node.name}</span>
{node.status !== 'unknown' && node.children && node.children.length > 0 && (
<ul>
{node.children.map((child) => renderNode(child))}
</ul>
)}
</li>
);
}
Progress, shop and friends
To keep players motivated beyond just the lessons, BashTeacher keeps track of daily streaks and unlocks achievements as lessons are completed. Coins earned from lessons can be spent in a shop on clothes and accessories for a penguin mascot, and players can add friends and compare progress with them on a leaderboard.
Whenever a lesson is completed, the backend recalculates the player’s streak from the date of their last completed lesson, rather than just incrementing a counter. That way a player who skips a day automatically has their streak reset:
const resolveNextStreak = (
currentStreak: number,
lastStreakActivityOn: string | Date | null,
activityDate: Date = new Date()
) => {
if (!lastStreakActivityOn) {
return 1;
}
const diffInDays = daysBetween(activityDate, lastStreakActivityOn);
if (diffInDays <= 0) return currentStreak; // Already counted today
if (diffInDays === 1) return currentStreak + 1; // Active yesterday, streak continues
return 1; // Missed a day, streak resets
};
The same lesson-completion step also checks whether the new streak or lesson count crosses any achievement thresholds, and unlocks them if they haven’t been unlocked already:
if (nextStreak >= 5) {
const alreadyUnlocked = await hasAchievement(githubId, FIVE_DAY_STREAK_ID);
if (!alreadyUnlocked) {
await unlockAchievement(githubId, FIVE_DAY_STREAK_ID);
unlockedAchievements.push(FIVE_DAY_STREAK_ID);
}
}