Skip to content
Tenzok
Home
Services
Student Projects
BlogAboutContact
All insights
Engineering insight1 July 2026·11 min read

How to Deploy Your Final Year Project to a Real URL

A localhost screenshot says "I got it working once." A live URL says "this runs without me." Here is the shortest honest path from your laptop to a real deployment: Docker, secrets, a health check, TLS, and CI/CD that ships on merge.

DeploymentDevOpsFinal Year ProjectCI/CD

If you want to know how to deploy your final year project, start by looking at your report. There is a good chance it contains a screenshot with localhost:3000 in the address bar. That one detail quietly tells the reader something you did not intend: this thing has only ever run on one laptop, in one terminal, with one person babysitting it.

A live URL changes how the whole project is read. Not because a domain is impressive, but because getting to a URL forces you to answer questions localhost never asks. Where do the secrets live? What happens when the process dies? Did the migrations actually run? Can someone other than you start this? Those are the questions an examiner circles around without always having the vocabulary for them, and they are exactly the questions a production system answers.

This is the shortest honest path from your machine to a real deployment: configuration out of the code, a container that builds the same way everywhere, a managed database, a health check that tells the truth, a reverse proxy that terminates TLS, and a pipeline that ships on merge. Real Dockerfile, real GitHub Actions YAML, and the specific things that will break the first time you try.

Why does a localhost screenshot read as unfinished?

Software that only runs on your laptop is not software yet. It is a set of instructions that happen to work because of things you did months ago and forgot: a Postgres you installed once, a .env that never left your home directory, a port that happened to be free, a node_modules you have not reinstalled since October. Deploying strips all of that away. The container starts from nothing, on a machine you have never touched, with only what you declared. Every implicit assumption becomes an explicit failure, which is the point. The deploy is not a formality at the end of the project. It is the first honest test the project has ever taken.

What actually counts as deployed?

  • A container image, versioned by commit SHA. One artefact, built once, shipped everywhere. With one caveat worth stating up front: an image built on an Apple Silicon laptop is linux/arm64 and will refuse to start on an amd64 server with exec format error. Build for the architecture the server actually runs.
  • Configuration from the environment. No secret, no hostname, no API key in the repository. Ever.
  • A managed database. Not a Postgres running inside your app container with data on a disk that vanishes on the next redeploy.
  • A health check endpoint that fails when the app is genuinely broken, plus something that acts on it. Docker alone will only tell you; acting on it needs an orchestrator or a platform that polls the endpoint.
  • A pipeline. Merge to main builds, tests, and deploys. If deploying is a manual ritual only you know, you have not deployed. You have performed.

Step 1: get the configuration out of the code

This is the change that unblocks everything else, and it is the one most projects skip. Here is the shape a project starts in when it has only ever run on one machine, and why it cannot be deployed as written.

ts
// src/server.ts — the version that cannot be deployed
import express from "express";
import cors from "cors";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: "postgres://postgres:root@localhost:5432/mydb",
});

const app = express();
app.use(cors({ origin: "http://localhost:5173" }));

app.listen(3000, "127.0.0.1", () => console.log("http://localhost:3000"));

Three fatal lines. The database URL is a hardcoded localhost that does not exist on the server. The CORS origin is your dev frontend, so the deployed frontend gets blocked. And listening on 127.0.0.1 inside a container means the process is reachable only from inside that container, so the platform's health check hits it, gets nothing, and marks your service dead. That last one is a miserable thing to debug, because the logs look fine.

ts
// src/server.ts — the version that deploys
import express from "express";
import cors from "cors";
import { Pool } from "pg";

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("DATABASE_URL is not set");

const origins = (process.env.CORS_ORIGINS ?? "")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);
if (origins.length === 0) throw new Error("CORS_ORIGINS is not set");

const pool = new Pool({
  connectionString: databaseUrl,
  // TLS on, and the server certificate is verified against Node's trusted roots.
  ssl: process.env.PGSSL === "require" ? { rejectUnauthorized: true } : undefined,
});

const app = express();
app.use(express.json());
app.use(cors({ origin: origins, credentials: true }));

const port = Number(process.env.PORT ?? 3000);
app.listen(port, "0.0.0.0", () => console.log(`listening on :${port}`));

Two things there are deliberate. First, it throws at boot on a missing DATABASE_URL and on a missing CORS_ORIGINS. An empty origins array handed to cors() blocks every origin silently, and a container that starts happily and then refuses every request is far worse to debug than one that dies with a clear message. Fail fast, fail loudly, fail at boot, and apply that rule to every required variable, not only the ones you remember. Second, rejectUnauthorized is true. You will find plenty of snippets that set it to false; that keeps the encryption and throws away the identity check, which means anyone who can get in the middle of the connection can read it. Managed Postgres providers present publicly trusted certificates, so verification simply works. If yours uses a private CA, pass its certificate with the ca option. Do not switch verification off to make an error go away.

If your frontend is Vite or Next.js, remember that VITE_ and NEXT_PUBLIC_ variables are baked in at build time, not read at runtime. Setting VITE_API_URL on the hosting dashboard after the build has already happened does nothing. It has to be present in the environment where the bundle is compiled, which usually means in your CI job.

Step 2: a Dockerfile you can actually trust

Multi-stage, non-root, production dependencies only, migrations included, health check baked in. This is a real Dockerfile for a TypeScript Express API that compiles to dist/ and uses node-pg-migrate for schema changes.

dockerfile
# syntax=docker/dockerfile:1

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build            # tsc -> /app/dist

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine AS runtime
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=deps  /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/migrations ./migrations
COPY package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
  CMD wget -qO- "http://127.0.0.1:${PORT}/healthz" || exit 1
CMD ["node", "dist/server.js"]

Every line there is load-bearing. The build stage needs TypeScript and every devDependency; the runtime image carries none of them. The separate deps stage installs production packages against the same lockfile, so what ships is exactly what you resolved. The migrations directory is copied into the runtime image on purpose, because the deploy runs migrations from this same image, which means the files have to be inside it and the migration tool has to sit in dependencies, not devDependencies, or npm ci --omit=dev will strip it out and the migrate command will die with cannot find module. Keep migration files as plain .js or .sql, not TypeScript, so the runtime image can execute them with no compiler present. USER node drops root; the official node images already create that user. wget ships inside Alpine's busybox, so the health check needs no extra package. Note also that the health check reads ${PORT} at runtime instead of hardcoding 3000, so if a platform hands you PORT=8080 the app and its own health check move together rather than the container declaring itself permanently unhealthy; EXPOSE is documentation only and does not need to follow. Finally, add a .dockerignore before your first build, or you will copy node_modules and .env straight into the image.

text
node_modules
dist
.git
.env
.env.*
*.log
coverage

Step 3: a health check that tells the truth

A health endpoint that returns 200 unconditionally is worse than none, because it teaches your platform to keep a broken app in rotation. Check the thing that actually breaks: the database connection. Returning the commit SHA alongside it is a small trick worth stealing. When you are standing in front of a projector and the demo behaves oddly, you can open /healthz and know in seconds whether the thing you are looking at is the code you think you deployed.

ts
app.get("/healthz", async (_req, res) => {
  try {
    await pool.query("select 1");
    res.status(200).json({ status: "ok", sha: process.env.GIT_SHA ?? "dev" });
  } catch (err) {
    console.error("healthcheck failed", err);
    res.status(503).json({ status: "degraded" });
  }
});

Be clear about what Docker does with this. HEALTHCHECK reports, it does not act: an unhealthy container keeps running, keeps its port bound, and keeps serving 503s. And --restart unless-stopped reacts to the process exiting, not to health status. Something else has to do the restarting: a PaaS that polls /healthz over HTTP and recycles the instance, an orchestrator (Compose with dependent restarts, Swarm, Kubernetes), or a small sidekick container such as autoheal. Until you add one of those, the endpoint is a truth-teller, not a repair mechanism.

Step 4: what has to exist on the server before the first deploy

  • Docker installed, and the deploy user added to the docker group (sudo usermod -aG docker deploy). Otherwise every command in the release script needs sudo and the SSH step fails on a permission error.
  • The deploy user's public key in /home/deploy/.ssh/authorized_keys. Generate a key for this and nothing else. Your personal key does not belong in a CI secret.
  • /srv/app/.env on the box, owned by deploy, chmod 600. It holds DATABASE_URL, CORS_ORIGINS, PGSSL and anything else the app needs. It never goes into git.
  • /srv/app/release.sh, copied across by hand the first time and made executable with chmod +x.
  • /srv/app/ghcr.token, chmod 600, containing a GitHub personal access token with read:packages and nothing else. GHCR packages are private by default, so without this the server's docker pull fails with denied. The alternative is to flip the package to public in its settings on GitHub, which is fine for an open-source project and wrong the moment the image contains anything you would not publish.
  • A reverse proxy that terminates TLS. The container publishes to loopback only, so nothing reaches it from the internet until a proxy sits in front. Caddy is the least work, because it obtains and renews the certificate for you.
text
# /etc/caddy/Caddyfile
yourdomain.com {
  reverse_proxy 127.0.0.1:3000
}

That is the entire proxy config. Point an A record at the server, open ports 80 and 443, reload Caddy, and https://yourdomain.com serves your container with a valid certificate. It is also why release.sh publishes with -p 127.0.0.1:3000:3000 rather than -p 3000:3000: binding to loopback means the only way in is through the proxy, so nobody can hit your app over plain HTTP on port 3000 and skip TLS entirely. Skip the proxy and that same loopback binding leaves you with a healthy container and a dead URL, which is the opposite of the thing this post promised you.

Step 5: the pipeline that ships on merge

This is a complete GitHub Actions workflow. It runs your tests against a real Postgres, builds the image for linux/amd64, pushes it to GitHub Container Registry tagged with the commit SHA, then deploys over SSH to any box running Docker. Put it in .github/workflows/deploy.yml.

yaml
name: ci-cd

on:
  push:
    branches: [main]
  pull_request:

env:
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: app_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run migrate
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
      - run: npm test
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test

  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64   # match the server, not your laptop
          tags: |
            ${{ env.IMAGE }}:${{ github.sha }}
            ${{ env.IMAGE }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    # two quick merges must not race: an older SHA landing last is a real bug
    concurrency:
      group: deploy-${{ github.ref }}
      cancel-in-progress: false
    steps:
      - name: Deploy over SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
          HOST: ${{ secrets.DEPLOY_HOST }}
          TAG: ${{ github.sha }}
        run: |
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          ssh -i ~/.ssh/id_ed25519 deploy@"$HOST" \
            "IMAGE=${{ env.IMAGE }}:$TAG /srv/app/release.sh"

Notice what the deploy step does not do: it does not run ssh-keyscan against the host on every deploy, which would mean trusting whatever key happens to answer, every single time. Run ssh-keyscan -H your.host once from your own machine, look at the output, and paste it into a DEPLOY_KNOWN_HOSTS secret. Now the pipeline refuses to talk to a server it has not been told about. On the server, release.sh does the part everyone forgets.

bash
#!/usr/bin/env bash
set -euo pipefail
: "${IMAGE:?IMAGE is not set}"

GHCR_USER=your-github-username

# GHCR packages are private by default: the server has to authenticate to pull.
docker login ghcr.io -u "$GHCR_USER" --password-stdin < /srv/app/ghcr.token

docker pull "$IMAGE"

# Migrations run once, from this same image, before the new code serves traffic.
# This only works because the runtime image contains ./migrations and the
# migration tool is a production dependency.
docker run --rm --env-file /srv/app/.env "$IMAGE" npm run migrate

docker rm -f app 2>/dev/null || true
docker run -d --name app --restart unless-stopped \
  --env-file /srv/app/.env -e GIT_SHA="${IMAGE##*:}" \
  -p 127.0.0.1:3000:3000 "$IMAGE"

echo "$IMAGE" >> /srv/app/deployed.log   # your rollback history

GHCR image names must be lowercase. If your GitHub username or repository name contains capital letters, ghcr.io/${{ github.repository }} fails with an obscure error. Hardcode the lowercase path instead. It is an easy detour to take on a first deploy, and an easy one to avoid.

Three secrets go in Settings, then Secrets and variables, then Actions: DEPLOY_SSH_KEY (the deploy-only private key), DEPLOY_HOST, and DEPLOY_KNOWN_HOSTS. GITHUB_TOKEN is issued to the workflow automatically, and it is what lets the build job push to GHCR. The read-only token that lets the server pull lives on the server, not in the repository. Nothing sensitive is committed.

Where do you actually host it, for free or nearly free?

  • Platform-as-a-service free tiers. Genuinely free and git-push simple. The catch is cold starts, and the specifics are per-provider rather than universal: Render, for example, documents that its free web services spin down after a period of inactivity and take time to come back on the next request. Read your provider's current docs rather than trusting a number in a blog post, this one included, and hit the URL a few minutes before any demo.
  • Managed Postgres (Neon, Supabase, and others). The free tiers are small but real. What you have to check is the idle policy, because it is written for hobby projects, not for a repo you leave alone between submission and viva. Both providers document how a free project behaves when it sits unused; go and read it, then open the app the day before the demo, not the hour before.
  • A small VPS, roughly the price of a coffee per month. No cold starts, full control, and the SSH pipeline above works untouched. You are now responsible for the firewall, TLS, and updates, which is a legitimate thing to learn and a legitimate thing to write about in your report.
  • Static frontend hosts (Vercel, Netlify, Cloudflare Pages). Free and excellent for a React or Next frontend. The API still has to live somewhere with a real backend runtime.
  • The GitHub Student Developer Pack. Free credits from several cloud providers, available to students who pass GitHub's verification, which usually means a school email or proof of enrolment.
  • What I would actually pick: frontend on a static host, API in a container on a small VPS, database on managed Postgres, TLS via Caddy. It is boring, it is cheap, and nothing in it will surprise you at 11pm the night before submission.

What breaks first, and roughly in what order

  1. 1CORS. Your frontend is on one origin, your API on another, and now the browser cares. The error will say the response has no Access-Control-Allow-Origin header. That is not a frontend bug; it is your API refusing to consent. Set CORS_ORIGINS to your deployed frontend origin exactly, including https and no trailing slash. Do not reach for app.use(cors()) with no arguments to make the red text go away: that opens your API to every origin on the internet, and a reviewer who knows what they are looking at will notice.
  2. 2A hardcoded localhost that survived. There is always one more, and it is usually in the frontend. Grep the whole repo before you claim victory; the command below catches most of them.
  3. 3Migrations that never ran. The app deploys, the container is green, and every request 500s with relation "users" does not exist. Your schema lived in a local database the server has never seen. Migrations have to be files in the repository, applied by a command in the deploy path, which is the docker run migrate line in release.sh. If your current schema exists only because you clicked through pgAdmin once, stop and write the migrations now. It is a manageable job today and a project-ending one on viva day.
  4. 4A secret committed to git. When it happens, rotate first. Deleting the file and force-pushing does not un-leak anything: public repos are scraped constantly, and the credential is compromised the moment it is pushed, not the moment someone notices. Revoke and reissue at the provider, then clean history with git filter-repo or BFG, then add the file to .gitignore. In that order. Then add a pre-commit scanner such as gitleaks and let a machine do the remembering.
  5. 5The container that starts and immediately exits. Almost always one of three things: the app bound to 127.0.0.1 instead of 0.0.0.0, it ignored the PORT the platform assigned, or a required environment variable was missing and the process threw at boot, which, if you followed step 1, is exactly what it should do. docker logs app tells you which. Read the logs before you rewrite anything.
bash
git grep -nE '(localhost|127\.0\.0\.1|:3000|:5173|:8000)' -- . ':!*.md' ':!package-lock.json'

The deploy does not add polish to the project. It exposes whether the project was ever real.

Making the demo survive the viva

  1. 1Seeded data. An empty deployed app is a bad demo. Write a seed script with idempotent inserts (ON CONFLICT DO NOTHING) that creates a demo user and enough realistic rows to show every feature, and run it once against production. Never demo an empty table.
  2. 2A known-good tag. When it works, tag it: git tag -a viva-ready -m 'demo build' && git push origin viva-ready. That commit SHA is also your image tag in GHCR. You now have a build you can name.
  3. 3A rollback you have actually rehearsed. Rolling back the code is one command on the server, IMAGE=ghcr.io/you/app:OLD_SHA /srv/app/release.sh, but the first time you run it must not be during the demo. Deploy something, break it on purpose, roll it back. And know the limit: rolling back the image does not roll back the database. If the bad deploy dropped a column, the old image will not bring it back, which is a good reason to keep destructive migrations for after the viva.
  4. 4A code freeze. Stop deploying a couple of days before the viva. A lot of demo-day failures trace back to a change pushed the night before, for reasons nobody can reconstruct at 9am.
  5. 5A warm-up pass. Before you walk in: open the URL, wake the free tier, confirm /healthz returns the SHA you expect, and keep a short screen recording of the working flow on your phone. Not as a substitute for the live demo, but as the thing that lets you keep talking calmly while the campus wifi does whatever campus wifi does.

The honest reason this matters

Nobody is going to give you extra marks for a Dockerfile. What deployment buys you is different and more durable: it is the first time your project has to survive without you in the room. Every assumption you made becomes visible. Every implicit dependency becomes a line in a config file. The project stops being a demonstration and becomes a system, and that shift, more than any framework on your CV, is the difference between a college project and engineering work.

We think student projects should be built the way production software is built: containerised, in version control, deployed from a pipeline, from week one rather than week twelve. That is the principle Tenzok works from, because a project that has never been deployed has never really been tested. If you are staring at a working localhost and a deadline, that is the part worth fixing first, whether you do it with us or on your own. Start with the .dockerignore. Then the environment variables. The rest follows.

Frequently asked

Questions people actually ask

How do I deploy my final year project for free?

Put the frontend on a static host (Vercel, Netlify, or Cloudflare Pages), run the backend as a Docker container on a platform-as-a-service free tier, and use a managed Postgres free tier such as Neon or Supabase. The main trade-off on free backend tiers is cold starts: Render, for example, documents that its free web services spin down after inactivity and take time to wake, and other providers have their own idle policies, so check the current terms and open the URL a few minutes before any demo. Also look at the GitHub Student Developer Pack, which offers cloud credits to students who pass GitHub's verification.

Do I really need Docker for a college project?

You do not strictly need it, but it is the cheapest way to guarantee the app runs the same on the server as on your laptop. Without a container you are relying on the hosting platform to guess your runtime, your build command, and your Node or Python version. A short Dockerfile removes all of that guessing, and it makes rollback (redeploy the previous image tag) a one-command operation. Build for the architecture your server runs: an image built on an Apple Silicon Mac is arm64 and will not start on an amd64 VPS.

What is the simplest CI/CD setup for a student project?

A single GitHub Actions workflow with three jobs: run tests against a Postgres service container, build and push a Docker image to GitHub Container Registry tagged with the commit SHA, then SSH into your server to pull and restart that image. Secrets live in GitHub Actions secrets, never in the repo. Migrations run as a step in the deploy, from the same image, before the new container starts serving. Remember that GHCR packages are private by default, so the server needs a read-only token to pull, or the package has to be made public.

I accidentally committed my .env file with API keys. What do I do?

Rotate the credentials at the provider first. Public repositories are scraped constantly, so the key is compromised the moment it was pushed, not when you noticed. Revoke and reissue, then remove the file from git history with git filter-repo or BFG, then add .env to .gitignore. Install a pre-commit scanner such as gitleaks so it does not happen again.

Why does my deployed API work locally but fail in the browser with a CORS error?

Your frontend and API are now on different origins, so the browser enforces CORS. Your API must send an Access-Control-Allow-Origin header listing the deployed frontend origin exactly, including the https scheme and with no trailing slash. Drive it from an environment variable rather than hardcoding it, and make the app throw at boot if that variable is missing, because an empty origin list silently blocks every request. Do not disable CORS entirely to make the error go away.

Apply it to your project

Stuck on this in your own build?

This is the kind of problem we work through in code reviews every week. Send the problem statement and we’ll tell you honestly whether the scope is right.

Talk to us

On this page

  • Why does a localhost screenshot read as unfinished?
  • What actually counts as deployed?
  • Step 1: get the configuration out of the code
  • Step 2: a Dockerfile you can actually trust
  • Step 3: a health check that tells the truth
  • Step 4: what has to exist on the server before the first deploy
  • Step 5: the pipeline that ships on merge
  • Where do you actually host it, for free or nearly free?
  • What breaks first, and roughly in what order
  • Making the demo survive the viva
  • The honest reason this matters

Need a second opinion?

Send the problem statement directly to the Tenzok team.

Email us

Keep reading

Related engineering notes

Browse all insights

14 July 2026 · 13 min

Spring Boot Microservices Project: What to Build, What to Skip

Most Spring Boot microservices projects are three CRUD apps in Docker with a Eureka server; here is what actually makes it a distributed-systems project, and what to cut.

Read article

8 July 2026 · 11 min

12 Viva Questions Examiners Ask About Your Final Year Project

Viva questions cluster into a few recognisable families, and in most vivas every one of them ends with the same follow-up: show me where that happens in the code.

Read article

23 June 2026 · 12 min

How to Build a RAG Chatbot That Actually Retrieves

Most RAG chatbot projects fail at retrieval, not generation — here is how to chunk on structure, store in pgvector, measure recall@k, and build a refusal path that actually fires.

Read article
Your next build starts here

Turn the idea into software people trust.

Bring us a product brief, a business problem, or a final-year project. We’ll turn it into a clear scope, a working build, and a handover you fully own.

Start Your ProjectSend your brief

Prefer email? info@tenzok.in

Tenzok

A product engineering studio for ambitious companies, founders, and students who want real, production-minded work.

info@tenzok.in

Company

HomeBlogAboutContactFAQ

Services

MentorshipStudent ProjectsCompany ServicesDigital MarketingLaunch Support

Project domains

Python Full-StackJava & EnterpriseAI & LLM ApplicationsMachine LearningExplore all 18 domainsRSS feed

© 2026 Tenzok. All rights reserved.

Obsession · Purpose · Excellence

Published by Tenzok. Contact info@tenzok.in.