Deploy a Music Tracker with CI/CD
Deploy a Next.js app to your home server with Docker, Nginx, and GitHub Actions.
Introduction
30 Second Summary
You have a spare laptop running Ubuntu sitting on your desk. Right now it does nothing. But with the right tools, that laptop becomes your own personal server, deploying apps the exact same way companies deploy to cloud servers.
In this project, you will build a music practice tracker and deploy it production-style on your Ubuntu laptop using Docker, Nginx, and PostgreSQL. You will set up a CI/CD pipeline so that pushing code from your Windows machine automatically rebuilds and redeploys the app on the server.
What You'll Build
When you finish, you will open your phone, type your server's IP address into the browser, and log a 30-minute guitar practice session. Then you will push a code change from your Windows laptop, refresh the page, and see the update live on your server within seconds.
By the end of this project, you'll have:
- A practice tracker app accessible from any device on your home network, where you log guitar and vocal sessions with duration, focus area, and notes.
- A persistent database that keeps your practice history safe across server reboots and container rebuilds.
- A push-to-deploy pipeline using GitHub Actions where every push to main from your Windows machine triggers an automatic rebuild on the server.
- Secret Mission: Add self-signed HTTPS so traffic between your devices and the server is encrypted.
Are there any prerequisites?
You need a GitHub account, Git installed on your Windows machine, and a separate Ubuntu 26.04 LTS laptop on the same WiFi network as your Windows machine. The Ubuntu laptop can be any old laptop you are not using day-to-day.
No prior Docker or Linux experience is required. This project walks you through every command.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. By the end of this project, you'll have a production-grade deployment running on your own Ubuntu laptop, using the same tools and architecture that power real servers on the internet.
Set Up Your Server and Repository
Your Ubuntu laptop is a blank slate right now. Without Docker, you cannot run containers. Without a GitHub repository, you have no way to get code from your Windows machine to the server.
This step turns your laptop into a deployment target by installing Docker, creating a repo, and registering a GitHub Actions self-hosted runner that will receive deploy jobs automatically.
In this step, get ready to:
- Install Docker CE and Docker Compose on the Ubuntu server.
- Create a GitHub repository and clone it on your Windows machine.
- Register a self-hosted runner on the server so it can receive deploy jobs.
Install Docker CE on the Ubuntu server
Docker CE is the container engine that will run your app, database, and reverse proxy. You will install it from Docker's official apt repository so you get automatic security updates.
- On your Ubuntu laptop, open a terminal.
- Add Docker's official GPG key and apt repository by running these commands:
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
What do these commands do?
The first group downloads Docker's GPG signing key so your system can verify that packages genuinely come from Docker.
The second group adds Docker's package repository to your system's sources list. The $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") automatically detects your Ubuntu version codename (resolute for 26.04).
- Install the Docker packages by running:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
What does this install?
This installs the Docker engine (docker-ce), the CLI, the container runtime, and the Compose plugin. After this, docker compose (with a space) is available as a subcommand.
- Add your user to the docker group so you can run Docker without sudo by running:
sudo usermod -aG docker $USER
- Log out and log back in for the group change to take effect. You can do this by running:
newgrp docker
- Verify Docker is working by running:
docker compose version
You should see a version number printed, confirming Docker Compose is installed and your user can run Docker without sudo.
Permission denied when running docker?
If you see a permission error, your group membership has not taken effect yet. Close your terminal completely and open a new one, or reboot the laptop. Then try docker compose version again.
help me fix Docker permission denied on Ubuntu
Why Docker CE from the official repo?
Ubuntu's default docker.io package may have compatibility issues with cgroup v2, which Ubuntu 26.04 uses exclusively. Docker CE from Docker's own repository handles cgroup v2 transparently.
Create a GitHub repository and clone it on Windows
You need a central place to store your code. Your Windows machine is where you will write code, and the Ubuntu server is where it deploys. GitHub connects the two.
- Open your browser on your Windows machine and go to github.com.
- Click the + icon in the top right, then select New repository.
- Enter practice-tracker as the repository name.
- Leave the repository set to Public (or Private, your choice).
- Do not check any boxes for README, .gitignore, or license.
- Click Create repository.
GitHub shows you a Quick Setup page with your repository URL.
- On your Windows machine, open a terminal (Git Bash, PowerShell, or Windows Terminal).
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone the repository by running:
git clone [[REPO_URL="https://github.com/YOUR-USERNAME/practice-tracker.git"]]
- Move into the project folder by running:
cd practice-tracker
- Confirm you are inside the repository by running:
git status
You should see a message like On branch main confirming you are inside a valid Git repository.
Getting a 'repository not found' error?
Double-check that you copied the full URL from the GitHub Quick Setup page. Make sure your GitHub account is authenticated on your Windows machine (you may need to sign in when prompted).
help me fix git clone repository not found
Register a GitHub Actions self-hosted runner on the server
A self-hosted runner is a program that lives on your server and listens for jobs from GitHub Actions. When you push code, GitHub tells your runner to pull the latest code and run deploy commands.
- In your browser, go to your practice-tracker repository on GitHub.
- Click Settings in the top navigation bar.
- In the left sidebar, click Actions, then click Runners.
- Click New self-hosted runner.
- Select Linux as the operating system and choose the architecture matching your Ubuntu laptop (most likely x64).
GitHub displays a set of shell commands for downloading, extracting, and configuring the runner. You will run these on your Ubuntu server.
Why copy from GitHub's page?
The download URL and registration token on this page are unique to your repository and expire after one hour. That is why you copy the commands directly from GitHub rather than using a fixed URL.
- Switch to your Ubuntu server terminal.
- Create a directory for the runner and navigate into it by running:
mkdir ~/actions-runner && cd ~/actions-runner
- Copy the download and extract commands shown on the GitHub page and run them on your server. They look similar to this (your URL and hash will differ):
# Download the runner (copy the exact curl command from GitHub)
curl -o actions-runner-linux-x64.tar.gz -L https://github.com/actions/runner/releases/download/v2.XXX.X/actions-runner-linux-x64-2.XXX.X.tar.gz
# Extract the runner
tar xzf ./actions-runner-linux-x64.tar.gz
Use the exact commands from GitHub
The version numbers and URL above are placeholders. Copy and paste the exact commands GitHub shows you on the New self-hosted runner page. They include the correct version and a checksum verification step.
- Run the configuration command shown on GitHub. It looks like this (your token will be different):
./config.sh --url https://github.com/YOUR-USERNAME/practice-tracker --token YOUR-TOKEN-HERE
- When prompted for a runner group, press Enter to accept the default.
- When prompted for a runner name, press Enter to use the hostname.
- When prompted for labels, press Enter to accept the defaults.
- When prompted for the work folder, press Enter to use _work.
The runner is now registered with GitHub. Next, install it as a service so it starts automatically on boot.
- Install the runner as a systemd service by running:
sudo ./svc.sh install
- Start the runner service by running:
sudo ./svc.sh start
What does svc.sh do?
The svc.sh script creates a systemd unit file so the runner starts automatically every time the server boots. You never have to manually start it again.
Before you check the result, what do you expect to see on the GitHub Runners page for your server?
- In your browser, go back to your repository's Settings > Actions > Runners page.
- Refresh the page.
You should see your server listed as a runner with a green dot and the status Idle. This confirms the runner is connected and waiting for jobs.
- Also verify Docker Compose on the server by running:
docker compose version
You should see a version number printed, confirming both Docker and Compose are ready.
Runner shows as Offline?
Make sure you ran both sudo ./svc.sh install and sudo ./svc.sh start. Check the service status by running sudo ./svc.sh status in the ~/actions-runner directory. If the output shows the service is not running, check that your server has internet access on port 443.
help me fix my GitHub Actions self-hosted runner showing as Offline
Your server is now a fully equipped deployment target. Docker is ready to run containers, your repository is cloned on Windows for development, and the self-hosted runner is waiting to execute deploy jobs. Next up, you will build the practice tracker app and deploy it to your server for the first time.
Build and Run the Practice Tracker
Your server is prepped with Docker and a runner waiting for jobs. Now you need an actual app to deploy.
In this step you will build a minimal practice tracker that logs guitar and vocal sessions. The app stores data in memory for now, which means it works but data vanishes on restart. That shortcoming is intentional and sets up the next step.
In this step, get ready to:
- Scaffold a Next.js app with a practice log form on your Windows machine.
- Write a multi-stage Dockerfile that produces a minimal production image.
- Push to GitHub and watch the self-hosted runner deploy the app to your server.
Scaffold the Next.js app and create the practice log page
You will write all the code on your Windows machine and push it to GitHub. The runner on Ubuntu handles building and running the containers.
You need Node.js installed on Windows to generate the lock file that Docker uses during the build. Next.js 16.3.0 requires Node.js 20.9.0 or higher.
- Check your Node.js version by running this in your terminal:
node --version
✔️ I see version 22 or higher
You are ready to continue. Node 22 matches the version used in the Docker build, so your lock file will be fully compatible.
ⓧ I see a version lower than 22
Upgrade to Node 22 LTS. The easiest path on Windows is with winget:
winget install OpenJS.NodeJS.LTS
Close and reopen your terminal, then run node --version again to confirm it shows v22 or higher.
ⓧ Command not found
Node.js is not installed. Install Node 22 LTS using winget:
winget install OpenJS.NodeJS.LTS
Close and reopen your terminal, then run node --version to confirm it prints v22 or higher.
- In your terminal on Windows, navigate to the cloned repository folder by running:
cd ~/Desktop
cd practice-tracker
- Create the folder structure the app needs by running:
mkdir -p src/app
mkdir public
- Create a file called package.json in the project root with the following content:
{
"name": "practice-tracker",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.3.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"pg": "8.16.0"
},
"devDependencies": {
"@types/node": "22.15.0",
"@types/react": "19.1.0",
"@types/pg": "8.11.11",
"typescript": "5.8.0"
}
}
What does this file do?
This defines your project's identity and its dependencies. next, react, and react-dom power the app. The pg package is the PostgreSQL driver you will use in the next step.
The devDependencies provide TypeScript type checking during the build but are excluded from the production image.
- Install the dependencies and generate the lock file by running:
npm install
- Confirm the install succeeded by running:
ls
You should see node_modules and package-lock.json listed alongside package.json.
npm install failed?
If you see a permissions error, make sure you opened a fresh terminal after installing Node.js. If npm reports a network issue, check your internet connection and retry.
help me debug an npm install failure on Windows
- Create next.config.ts in the project root with this content:
import type { NextConfig } from "next";
// Standalone output produces a minimal server bundle for Docker
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
What does standalone output do?
The standalone setting tells Next.js to bundle only the files needed to run in production. Instead of shipping the entire node_modules folder, it copies just the required dependencies into a self-contained .next/standalone directory with a server.js entry point.
- Create tsconfig.json in the project root with this content:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{"name": "next"}],
"paths": {"@/*": ["./src/*"]}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
- Confirm both config files exist by running:
ls *.ts *.json
You should see next.config.ts, tsconfig.json, package.json, and package-lock.json listed.
- Create src/app/layout.tsx with the following content:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Practice Tracker",
description: "Track your guitar and vocal practice sessions",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body style={{ fontFamily: "system-ui, sans-serif", maxWidth: "600px", margin: "0 auto", padding: "1rem" }}>
{children}
</body>
</html>
);
}
What does this file do?
Every Next.js App Router project needs a root layout. It wraps every page with the <html> and <body> tags. The inline styles center the content at 600px max width for a clean reading experience on any screen.
- Create src/app/page.tsx. Start with the data layer at the top of the file:
import { revalidatePath } from "next/cache";
interface Session {
id: number;
instrument: string;
duration_minutes: number;
focus_area: string;
notes: string;
created_at: string;
}
// In-memory storage (data resets when the container restarts)
const sessions: Session[] = [];
let nextId = 1;
async function addSession(formData: FormData) {
"use server";
const instrument = formData.get("instrument") as string;
const duration = parseInt(formData.get("duration") as string, 10);
const focusArea = formData.get("focus_area") as string;
const notes = formData.get("notes") as string;
sessions.unshift({
id: nextId++,
instrument,
duration_minutes: duration,
focus_area: focusArea,
notes,
created_at: new Date().toISOString(),
});
revalidatePath("/");
}
What does this code do?
The sessions array lives in server memory. Each time a user submits the form, the addSession Server Action pushes a new entry to the front of the array.
The "use server" directive marks this function as a Server Action. It runs on the server when the form is submitted, so no API route is needed.
- Below the addSession function in the same file, add the Home component that renders the form and session list:
export default function Home() {
return (
<main>
<h1>Practice Tracker</h1>
<form action={addSession}>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="instrument">Instrument: </label>
<select name="instrument" id="instrument" required>
<option value="guitar">Guitar</option>
<option value="vocals">Vocals</option>
</select>
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="duration">Duration (min): </label>
<input type="number" name="duration" id="duration" min="1" max="480" required />
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="focus_area">Focus: </label>
<input type="text" name="focus_area" id="focus_area" placeholder="e.g. chord transitions, breathing" required />
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="notes">Notes: </label>
<textarea name="notes" id="notes" rows={2} placeholder="Optional notes"></textarea>
</div>
<button type="submit">Log Session</button>
</form>
</main>
);
}
What does this component do?
The form posts directly to the addSession Server Action via the action attribute. No JavaScript event handlers or fetch calls needed. The browser submits the form, the server processes it, and Next.js re-renders the page.
- Still in src/app/page.tsx, find the closing </form> tag. Add the session list below it, before the closing </main> tag:
<h2>Recent Sessions</h2>
{sessions.length === 0 ? (
<p>No sessions yet. Log your first practice!</p>
) : (
<ul>
{sessions.map((s) => (
<li key={s.id}>
<strong>{s.instrument}</strong> - {s.duration_minutes} min - {s.focus_area}
{s.notes && <span> ({s.notes})</span>}
<br />
<small>{new Date(s.created_at).toLocaleString()}</small>
</li>
))}
</ul>
)}
What does this section do?
If the sessions array is empty, it shows a placeholder message. Otherwise it maps each session into a list item showing the instrument, duration, focus area, and timestamp.
- Save all files. Test that the app compiles by running the development server:
npm run dev
Open http://localhost:3000 in your browser. You should see the heading "Practice Tracker" with a form showing Instrument, Duration, Focus, and Notes fields.
- Submit a test session (choose Guitar, enter 15 minutes, type "scales" for focus).
- Confirm the session appears in the list below the form.
- Stop the dev server by pressing Ctrl+C in your terminal.
Page not loading at localhost:3000?
Check the terminal for compilation errors. A missing comma in tsconfig.json or a typo in the page.tsx JSX will prevent the build. Fix any red error messages and save the file again.
help me fix my Next.js dev server compilation error
Write the multi-stage Dockerfile and Compose configuration
The app runs locally. Now you need to package it into a Docker image that the server can run in production mode.
- Create a file called Dockerfile (no extension) in the project root with this content:
FROM node:22-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
What does this Dockerfile do?
It uses four stages to keep the final image small. The deps stage installs production dependencies. The builder stage installs everything and runs npm run build to produce the standalone output.
The runner stage copies only the built artifacts and runs as a non-root user called nextjs. The result is a minimal image with just the server and its dependencies.
- Create compose.yaml in the project root with this content:
services:
app:
build: .
ports:
- "3000:3000"
restart: unless-stopped
What does this file do?
Docker Compose reads this file to build and run your app container. The ports mapping exposes port 3000 on the host so you can reach the app from your network. The restart: unless-stopped policy means the container auto-restarts after a server reboot.
- Confirm both Docker files exist by running:
ls Dockerfile compose.yaml
You should see both files listed.
Dockerfile not showing up?
Make sure the file is named exactly Dockerfile with a capital D and no file extension. Some editors on Windows may add .txt automatically. Check with ls D* to see what exists.
help me find why my Dockerfile is missing or misnamed
Push to GitHub and deploy via the CI/CD pipeline
The code is ready on your Windows machine. Now you need a GitHub Actions workflow so that pushing to main triggers an automatic build on your server.
- Create the workflow directory and file by running:
mkdir -p .github/workflows
- Create .github/workflows/deploy.yml with this content:
name: Deploy to Home Server
on:
push:
branches:
- main
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Deploy with Docker Compose
run: |
docker compose down
docker compose up --build -d
What does this workflow do?
Every time you push to main, GitHub dispatches this job to your self-hosted runner on the Ubuntu server. The runner checks out the latest code, tears down any running containers, and rebuilds them from scratch.
The runs-on: self-hosted label tells GitHub to send this job to YOUR runner, not a GitHub-hosted cloud machine.
- Create a .gitignore file so you do not push node_modules to GitHub:
echo "node_modules/
.next/" > .gitignore
- Stage, commit, and push all your code by running:
git add .
git commit -m "Initial practice tracker with Docker deploy"
git push origin main
Before you check the result, what do you think will happen on the server? The runner should pick up the job, build the Docker image, and start the container.
- Open your GitHub repository in a browser.
- Click the Actions tab.
- Watch the "Deploy to Home Server" workflow run. It should complete with a green checkmark within a minute or two.
Why does the first build take longer?
Docker downloads the node:22-alpine base image and installs all npm packages from scratch on the first run. Subsequent pushes reuse cached layers and finish much faster.
- Once the workflow shows green, open a browser on your Windows machine and navigate to:
http://[[SERVER_IP="your Ubuntu server's IP address"]]:3000
You should see the Practice Tracker page with the same form you tested locally.
- Submit a practice session from your Windows browser (select Vocals, enter 20 minutes, type "breathing" for focus).
- Confirm the session appears in the Recent Sessions list below the form.
Workflow failed or page not loading?
If the Actions tab shows a red X, click into the failed run and read the error log. Common causes: the runner is offline (check the Runners page in repo Settings), or a typo in the Dockerfile prevents the build.
If the workflow succeeded but the page does not load at port 3000, SSH into your Ubuntu server and run docker compose logs app to see the container output.
help me debug my failed GitHub Actions deployment
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and your app is responding at http://<server-ip>:3000.
ⓧ I'd like to double check the full code
Scroll back through the chunks you built in this step and confirm each one is present in your project, in this order:
- package.json in the project root.
- next.config.ts in the project root.
- tsconfig.json in the project root.
- src/app/layout.tsx with the metadata and RootLayout function.
- src/app/page.tsx with the in-memory sessions array, addSession Server Action, and Home component (form + session list).
- Dockerfile with four stages (base, deps, builder, runner).
- compose.yaml with the app service on port 3000.
- .github/workflows/deploy.yml triggering on push to main.
Your practice tracker is live on the network. The catch is that session data lives in memory. Restart the container and everything vanishes. Next up, you will add PostgreSQL so your practice history survives rebuilds and reboots.
Add PostgreSQL for Persistent Data
Your practice tracker works, but it has a critical flaw hiding beneath the surface. Every session you log lives in memory inside the Docker container.
Rebuild or restart that container and everything vanishes. A real production app needs a database that survives container restarts. In this step, you will add PostgreSQL to your Docker stack so practice sessions persist permanently.
In this step, get ready to:
- Expose the data-loss problem by restarting your containers.
- Add a PostgreSQL service with persistent storage to your Docker Compose stack.
- Connect the practice tracker to PostgreSQL so sessions survive rebuilds.
Expose the data-loss problem
In Step 2, you logged a practice session and saw it appear in the browser. That session exists only in memory inside the running container.
Before you run the next command, predict what will happen to your logged session when Docker tears down the container and creates a fresh one.
- On your Ubuntu server, restart the stack by running these commands:
docker compose down
docker compose up --build -d
- Refresh http://<server-ip>:3000 in your Windows browser.
Your practice session is gone. The list is empty because the new container started with a fresh in-memory array. This is the gap that a database closes.
Why did the data disappear?
Docker containers are ephemeral. When you run docker compose down, the container and its in-memory state are destroyed. A new docker compose up creates a brand-new container from scratch.
A database running in its own container with a persistent volume solves this. The volume lives on the host filesystem, independent of any container lifecycle.
Add PostgreSQL to your Docker Compose stack
You need a PostgreSQL container alongside your app. The two containers will share a Docker network so the app can reach the database by hostname.
- On your Windows machine, open compose.yaml and replace its entire contents with this updated app service:
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://practiceuser:practicepass@db:5432/practicedb
depends_on:
db:
condition: service_healthy
restart: unless-stopped
networks:
- frontend
- backend
What does this code do?
- DATABASE_URL tells the app where to find PostgreSQL. The hostname db matches the service name you will define next.
- depends_on with service_healthy prevents the app from starting until PostgreSQL passes its health check.
- Two networks (frontend and backend) isolate traffic. The app bridges both so it can talk to the database and later to Nginx.
- Directly below the app service (still inside compose.yaml), add the database service and infrastructure:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: practicedb
POSTGRES_USER: practiceuser
POSTGRES_PASSWORD: practicepass
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U practiceuser -d practicedb"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- backend
volumes:
pgdata:
networks:
frontend:
backend:
What does this code do?
- postgres:17-alpine pulls a lightweight PostgreSQL 17 image.
- The named volume pgdata stores database files on the host. It survives docker compose down because volumes persist independently of containers.
- The init.sql mount tells PostgreSQL to run that script on first startup (when the volume is empty).
- The healthcheck runs pg_isready every 5 seconds so Compose knows when the database is ready for connections.
- Save compose.yaml.
YAML indentation errors?
Make sure db is at the same indentation level as app (two spaces under services:). The volumes: and networks: blocks sit at the root level (no indentation).
help me fix YAML indentation in my compose.yaml
Create the init script and connect the app to PostgreSQL
PostgreSQL needs a table to store sessions. Files placed in /docker-entrypoint-initdb.d/ inside the container run automatically the first time the database volume is created.
- Create a new file called init.sql in the project root (same folder as compose.yaml) with this content:
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
instrument VARCHAR(50) NOT NULL,
duration_minutes INTEGER NOT NULL,
focus_area VARCHAR(200) NOT NULL,
notes TEXT DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
What does this code do?
This creates a sessions table with columns for instrument, duration, focus area, notes, and a timestamp. IF NOT EXISTS makes it safe to re-run. The SERIAL type auto-increments the ID for each new row.
- Save init.sql.
- Confirm both files exist by running:
ls compose.yaml init.sql
You should see both filenames printed.
Now you need to update the app code to use PostgreSQL instead of the in-memory array. The pg package is already in your package.json from the scaffold, so no extra install is needed.
- Open src/app/page.tsx and replace its entire contents. Start with the imports, connection pool, and data-fetching function:
import { Pool } from "pg";
import { revalidatePath } from "next/cache";
// Create a connection pool using the DATABASE_URL from compose.yaml
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// Define the shape of a practice session row
interface Session {
id: number;
instrument: string;
duration_minutes: number;
focus_area: string;
notes: string;
created_at: string;
}
// Fetch the 20 most recent sessions from the database
async function getSessions(): Promise<Session[]> {
const result = await pool.query(
"SELECT * FROM sessions ORDER BY created_at DESC LIMIT 20"
);
return result.rows;
}
What does this code do?
- Pool from the pg library manages a pool of database connections. It reads DATABASE_URL which Docker Compose injects at runtime.
- getSessions() queries PostgreSQL directly from a Server Component. No API route needed.
- revalidatePath will refresh the page cache after a new session is added.
- Directly below the getSessions function, add the Server Action that inserts new sessions:
// Server Action: insert a new practice session into the database
async function addSession(formData: FormData) {
"use server";
const instrument = formData.get("instrument") as string;
const duration = parseInt(formData.get("duration") as string, 10);
const focusArea = formData.get("focus_area") as string;
const notes = formData.get("notes") as string;
await pool.query(
"INSERT INTO sessions (instrument, duration_minutes, focus_area, notes) VALUES ($1, $2, $3, $4)",
[instrument, duration, focusArea, notes]
);
revalidatePath("/");
}
What does this code do?
- "use server" marks this as a Server Action. The form calls it directly without a separate API endpoint.
- Parameterized queries ($1, $2, $3, $4) prevent SQL injection by letting PostgreSQL handle value escaping.
- revalidatePath("/") tells Next.js to refetch the page data so the new session appears immediately.
- Below addSession, add the first part of the page component with the form:
export default async function Home() {
const sessions = await getSessions();
return (
<main>
<h1>Practice Tracker</h1>
<form action={addSession}>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="instrument">Instrument: </label>
<select name="instrument" id="instrument" required>
<option value="guitar">Guitar</option>
<option value="vocals">Vocals</option>
</select>
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="duration">Duration (min): </label>
<input type="number" name="duration" id="duration" min="1" max="480" required />
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="focus_area">Focus: </label>
<input type="text" name="focus_area" id="focus_area" placeholder="e.g. chord transitions, breathing" required />
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label htmlFor="notes">Notes: </label>
<textarea name="notes" id="notes" rows={2} placeholder="Optional notes"></textarea>
</div>
<button type="submit">Log Session</button>
</form>
- Immediately after the closing </form> tag (still inside the return statement), add the sessions list and close the component:
<h2>Recent Sessions</h2>
{sessions.length === 0 ? (
<p>No sessions yet. Log your first practice!</p>
) : (
<ul>
{sessions.map((s) => (
<li key={s.id}>
<strong>{s.instrument}</strong> - {s.duration_minutes} min - {s.focus_area}
{s.notes && <span> ({s.notes})</span>}
<br />
<small>{new Date(s.created_at).toLocaleString()}</small>
</li>
))}
</ul>
)}
</main>
);
}
What changed from the in-memory version?
- The component is now async so it can await the database query directly.
- The form's action attribute points to the addSession Server Action instead of manipulating an in-memory array.
- Sessions now come from PostgreSQL, so they persist across container restarts.
- Save src/app/page.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved both compose.yaml, init.sql, and src/app/page.tsx.
ⓧ I'd like to double check the full code
Scroll up and confirm these sections are present in src/app/page.tsx, in order:
- Imports (Pool from pg, revalidatePath from next/cache) and the connection pool.
- The Session interface and getSessions() function.
- The addSession() Server Action with "use server".
- The Home component with the form and sessions list.
For compose.yaml, confirm you have the app service with DATABASE_URL, the db service with postgres:17-alpine, and the volumes: and networks: sections at the bottom.
Now push your changes so the CI/CD pipeline deploys the new stack.
- Commit and push from your Windows machine by running:
git add .
git commit -m "Add PostgreSQL for persistent sessions"
git push origin main
- Watch the Actions tab in your GitHub repository. Wait for the workflow to complete (about 30-60 seconds for the build).
First deploy with a database takes longer
Docker needs to pull the postgres:17-alpine image the first time. Subsequent deploys skip this step because the image is cached on the server.
Before you test, predict: if you log a session, then run docker compose down and docker compose up -d, will the session still be there this time?
- Open http://<server-ip>:3000 in your Windows browser.
- Log a practice session using the form.
- On the Ubuntu server, run these commands to restart the stack without the volume:
docker compose down
docker compose up -d
- Refresh http://<server-ip>:3000 in your browser.
Your session is still there. PostgreSQL stored it in the pgdata volume, which persists independently of the container lifecycle.
App not connecting to the database?
- Check that init.sql is in the same folder as compose.yaml. If it is missing, the volume mount fails silently and PostgreSQL has no sessions table.
- If you edited init.sql after the first deploy, the script will not re-run. Remove the volume with docker compose down -v then docker compose up -d to start fresh.
- Confirm the db container is healthy by running docker compose ps. The db service should show healthy in the status column.
help me debug my PostgreSQL connection in Docker Compose
Your practice data is now bulletproof. Next up, you will put Nginx in front of the app so it is no longer exposed directly on port 3000.
Add Nginx as a Reverse Proxy
Right now your practice tracker faces the network directly on port 3000. On a real VPS, exposing your application server like this is insecure and unprofessional.
In production, a reverse proxy sits in front of your app. It accepts all incoming traffic on port 80, filters malformed requests, and forwards only clean traffic to the app behind it. This is the architecture every production deployment uses.
In this step, get ready to:
- Create an Nginx configuration that proxies traffic to the Next.js container.
- Add Nginx to your Docker Compose stack and remove direct port exposure.
- Lock down the server with UFW so only ports 80 and 22 are reachable.
Create the Nginx reverse proxy configuration
Nginx needs a configuration file that tells it where to send incoming requests. You will create this file in your project on the Windows machine, and Docker will mount it into the Nginx container.
- In your project folder on Windows, create a new file called nginx.conf in the root of your repository (next to compose.yaml).
- Paste the following configuration into nginx.conf:
events {
worker_connections 1024;
}
http {
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}
What does this configuration do?
- upstream nextjs defines a named backend pointing at your app container on its internal port 3000. Docker DNS resolves the name app to the container's IP.
- listen 80 makes Nginx accept traffic on port 80. server_name _ is a catch-all that matches any hostname.
- proxy_http_version 1.1 with the Upgrade and Connection headers enables HTTP streaming, which the Next.js App Router uses for server-side rendering.
- The X-Real-IP and X-Forwarded-For headers pass the client's real IP address through to your app.
Why a reverse proxy instead of exposing the app directly?
The Next.js docs recommend placing a reverse proxy like Nginx in front of your Next.js server rather than exposing it directly to the internet. Nginx handles malformed requests, connection limits, and static asset caching before traffic ever reaches your application code.
This is also the same architecture you would use on a real VPS with a public IP. Learning it now on your home server means the pattern transfers directly when you deploy to a cloud provider.
- Save nginx.conf.
Add the Nginx service to Docker Compose and deploy
With the configuration ready, you need to tell Docker Compose to run an Nginx container and route external traffic through it instead of directly to the app.
- Open compose.yaml on your Windows machine.
- Add the following nginx service below the db service (before the volumes: section):
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
restart: unless-stopped
networks:
- frontend
What does this service do?
- ports: "80:80" publishes port 80 from the Nginx container to the host. This is now the only entry point for external traffic.
- The volume mount maps your local nginx.conf into the container as read-only (:ro).
- depends_on: app ensures Nginx starts after the Next.js container is running.
- Nginx is on the frontend network only. It can reach the app container but cannot access the database directly. This network segmentation is a production security practice.
Notice that the app service does not publish any ports to the host. Only Nginx exposes port 80 externally. The app container is reachable only through Docker's internal network.
- Save compose.yaml.
- Push your changes to GitHub by running these commands in your Windows terminal:
git add nginx.conf compose.yaml
git commit -m "Add Nginx reverse proxy"
git push origin main
The self-hosted runner on your Ubuntu server picks up the push and runs docker compose up --build -d. This time it pulls the nginx:1.27-alpine image and starts the Nginx container alongside your app and database.
Before you check the result, what do you expect to see when you navigate to your server's IP on port 80?
- Open your Windows browser and navigate to http://your-server-ip (no port number needed).
Your practice tracker loads on port 80. Nginx received the request and proxied it to the app container behind the scenes.
Page not loading on port 80?
- Check the GitHub Actions tab to confirm the deployment workflow completed successfully.
- SSH into your server and run docker compose ps to verify all three containers (app, db, nginx) are running.
- If the nginx container keeps restarting, check logs with docker compose logs nginx. A syntax error in nginx.conf is the most common cause.
Still stuck? Help me debug why Nginx is not proxying traffic to my Next.js container on port 80.
✔️ Awesome, I've got everything!
Great. Double check you have saved both nginx.conf and compose.yaml.
ⓧ I'd like to double check the full code
Your nginx.conf should look exactly like this:
events {
worker_connections 1024;
}
http {
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}
Your compose.yaml should contain three services (app, db, nginx). Confirm:
- The app service has no ports section.
- The nginx service publishes "80:80", mounts ./nginx.conf:/etc/nginx/nginx.conf:ro, depends on app, and is on the frontend network.
- The volumes: and networks: sections at the bottom still declare pgdata, frontend, and backend.
Lock down the server with UFW
Your app now works on port 80 through Nginx. But port 3000 is still technically reachable if someone knows to try it. A firewall closes that gap by explicitly allowing only the ports you choose.
- SSH into your Ubuntu server from your Windows machine.
- Allow SSH traffic so you do not lock yourself out by running:
sudo ufw allow 22/tcp
- Allow HTTP traffic on port 80 by running:
sudo ufw allow 80/tcp
- Set the default policy to deny all other incoming traffic by running:
sudo ufw default deny incoming
- Enable the firewall by running:
sudo ufw enable
What is UFW?
UFW (Uncomplicated Firewall) is Ubuntu's built-in firewall management tool. It wraps the lower-level iptables rules into simple commands. On installation, UFW is disabled with a default incoming policy of deny. Enabling it activates that deny-all baseline plus your explicit allow rules.
By allowing only ports 22 (SSH) and 80 (HTTP), you ensure that the only way to reach your app from outside the server is through Nginx.
- Confirm your rules are active by running:
sudo ufw status
You should see output showing port 22/tcp and 80/tcp as ALLOW, with everything else denied by default.
Now for the real test. What do you think will happen if you try to access port 3000 directly?
- In your Windows browser, try navigating to http://your-server-ip:3000.
The connection is refused or times out. The firewall blocks port 3000 from external access. Your app is only reachable through Nginx on port 80.
Port 3000 still reachable?
- Run sudo ufw status and confirm you see only 22/tcp and 80/tcp listed as ALLOW.
- Make sure UFW is actually enabled. The status output should say Status: active at the top.
- Docker can bypass UFW in some configurations. If port 3000 is still reachable, confirm that the app service in compose.yaml does not have a ports section. Only the nginx service should publish ports.
Still stuck? Help me figure out why port 3000 is still accessible after enabling UFW on my Ubuntu server.
Your practice tracker is now running behind a proper reverse proxy with a locked-down firewall. This is identical to how you would deploy on a real cloud VPS. Next up, you will wire up the CI/CD pipeline so that pushing code from your Windows machine auto-deploys changes without touching the server.
Wire Up the CI/CD Pipeline
Your practice tracker is running behind Nginx, locked down with a firewall, and storing data in PostgreSQL. The full production architecture is in place.
One problem remains: deploying a change means SSHing into the server and running commands manually. A real production workflow deploys automatically when you push code. This step closes the loop so your Windows machine becomes a true development workstation that ships changes with git push.
In this step, get ready to:
- Add a health check to the deploy workflow so failed deployments are caught automatically.
- Build a streak counter feature to prove the pipeline deploys real code changes.
- Push to main and watch the full CI/CD cycle run without touching the server.
Add a health check to the deploy workflow
Your workflow currently rebuilds and restarts the containers, but it has no way of knowing whether the app actually came back online. A health check step confirms the deployment succeeded before marking the workflow as green.
- On your Windows machine, open .github/workflows/deploy.yml in your code editor.
- Find the Deploy with Docker Compose step at the bottom of the file. Add this new step below it:
- name: Health check
run: |
sleep 10
curl -f http://localhost || exit 1
What does this step do?
- sleep 10 gives the containers time to start and respond to requests after the rebuild.
- curl -f http://localhost sends an HTTP request to the server. The -f flag makes curl return a non-zero exit code if the request fails.
- || exit 1 ensures the workflow step fails if curl cannot reach the app. GitHub Actions marks the run as failed and alerts you.
- Save deploy.yml.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
name: Deploy to Home Server
on:
push:
branches:
- main
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Deploy with Docker Compose
run: |
docker compose down
docker compose up --build -d
- name: Health check
run: |
sleep 10
curl -f http://localhost || exit 1
Add a streak counter to the practice tracker
To prove the pipeline works end-to-end, you need a visible code change that will appear on the live site after a push. A streak counter showing consecutive practice days is a perfect test.
- Open src/app/page.tsx on your Windows machine.
- Find this line inside the Home function:
const sessions = await getSessions();
- Directly below that line, add the streak calculation logic:
// Streak counter: count consecutive days with at least one session
const dates = sessions.map((s) => new Date(s.created_at).toDateString());
const uniqueDates = [...new Set(dates)];
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setDate(today.getDate() - i);
if (uniqueDates.includes(d.toDateString())) {
streak++;
} else {
break;
}
}
What does this code do?
- The calculation maps each session to its date string, deduplicates them, and walks backwards from today counting consecutive days that have at least one session.
- The loop breaks as soon as it finds a day with no practice, giving you the current streak.
- Now scroll down to the JSX return. Find this line:
<h1>Practice Tracker</h1>
- Add this line directly below the <h1> tag:
<p>🔥 Current streak: <strong>{streak} day{streak !== 1 ? 's' : ''}</strong></p>
- Save page.tsx.
Seeing a red underline in your editor?
- Make sure the streak calculation code sits between const sessions = await getSessions(); and the return ( statement. If it is inside the return, TypeScript will complain.
- Check that you have the closing brace } of the for loop before the return.
- Help me debug this TypeScript error
✔️ Awesome, I've got everything!
Great. Double check you have saved page.tsx before continuing.
ⓧ I'd like to double check the full code
The full page.tsx is longer than a quick glance, so confirm the following pieces are in place, in this order:
- Imports (Pool from pg, revalidatePath from next/cache) and the pool constant at the top.
- The Session interface, getSessions function, and addSession server action.
- Inside Home: const sessions = await getSessions(); followed immediately by the streak calculation (the block you just added).
- In the return JSX: <h1>Practice Tracker</h1> then the streak <p> tag, then the form, then the Recent Sessions section.
Push to main and watch the pipeline deploy
Both changes are ready on your Windows machine. Time to push them to GitHub and let the self-hosted runner do the deployment for you.
- Commit and push both changes by running these commands in your project terminal on Windows:
git add .
git commit -m "Add health check and streak counter"
git push origin main
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
- You should see a new workflow run triggered by your push. Click into it to watch the steps execute in real time.
What is happening on the server?
Your self-hosted runner receives the job, checks out the latest code, runs docker compose down to stop the old containers, then docker compose up --build -d to rebuild and start the new ones. Finally it waits 10 seconds and hits http://localhost to confirm the app is alive.
You did not SSH into the server. You did not run any commands there. The push triggered everything automatically.
- Wait for the workflow run to show a green checkmark. This means the deploy and health check both passed.
Before you refresh the browser, what do you expect to see on the page that was not there before?
- Open your Windows browser and navigate to http://your server's IP address (no port number needed).
You should see the streak counter displayed below the heading: a fire emoji followed by "Current streak: 1 day" (assuming you logged a session today). The change deployed automatically from your push.
Streak counter not showing up?
- Check the Actions tab on GitHub. If the workflow failed, click into the run to see which step errored. A health check failure means the containers did not start correctly.
- If the workflow passed but the page looks old, hard-refresh your browser with Ctrl+Shift+R to bypass the cache.
- SSH into your server and run docker compose logs app to check for build errors in the Next.js container.
- Help me debug my failed deployment
Your CI/CD pipeline is fully operational. Every push to main from your Windows machine automatically rebuilds and redeploys the app on your server, verified by the health check. Next up, the Secret Mission challenges you to add HTTPS encryption to your home server.
Secret mission
Add Self-Signed HTTPS
Your practice tracker is live on the network, but traffic flows in plain text. Anyone on your WiFi could snoop on your practice data. In this secret mission, you will generate a TLS certificate, configure Nginx to terminate HTTPS, and redirect all HTTP traffic to the encrypted connection.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your local network using your own hardware, so there are no ongoing financial costs.
Resources you used:
- Docker containers (Next.js app, PostgreSQL, Nginx) running on your Ubuntu server. These consume RAM and CPU while running.
- A PostgreSQL Docker volume (pgdata) holding your practice session data.
- A GitHub Actions self-hosted runner registered to your repository.
- Project files on your Ubuntu server and your Windows machine.
Keep everything running
No action needed. Choose this if you are still actively building or want your practice tracker available 24/7 on your network.
- Your Docker stack uses restart: unless-stopped, so it automatically starts when your Ubuntu server boots.
- The practice tracker remains accessible at https://<server-ip> from any device on your network.
- The self-hosted runner stays idle until you push a change, using minimal resources.
Pause - I'll come back to this later
Shut down running containers to free up memory and CPU. Your data volume and project files remain intact so you can resume anytime.
- On your Ubuntu server, navigate to your project directory and run:
docker compose down
- This stops and removes all containers but keeps the pgdata volume with your practice sessions.
- To resume later, run docker compose up -d from the same directory.
Delete - I don't want to use this again
Remove all project resources completely and start fresh.
- On your Ubuntu server, navigate to your project directory and remove all containers AND the data volume:
docker compose down -v
- Remove the self-hosted runner from GitHub. Go to your repository on github.com, then navigate to Settings, then Actions, then Runners. Click the runner name and click Remove.
- Remove the UFW rules you added:
sudo ufw delete allow 80/tcp
sudo ufw delete allow 443/tcp
- Delete the project files on your Ubuntu server:
cd ~
rm -rf practice-tracker
- Delete the project folder on your Windows machine. Open File Explorer, navigate to your Desktop, right-click the practice-tracker folder, and select Delete.
- Optionally, uninstall Docker CE from your Ubuntu server:
sudo apt purge docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo rm -rf /var/lib/docker /var/lib/containerd
Nice Work!
Nice Work!
You just deployed a production-grade music practice tracker to your own home server. From a blank Ubuntu laptop to a fully automated deployment pipeline, you now have the same architecture that runs on real VPS providers around the world.
You've learned how to:
- Deploy a Next.js application with PostgreSQL behind an Nginx reverse proxy on a home Ubuntu server, locked down with UFW firewall rules.
- Orchestrate a multi-container production stack with Docker Compose, including persistent volumes, health checks, and network segmentation.
- Build a CI/CD pipeline with GitHub Actions self-hosted runners for push-to-deploy from your Windows machine.
- Secret Mission: Added self-signed HTTPS with TLS termination at Nginx for encrypted local traffic.
Ready to quiz yourself?