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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- CORS. 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.
- A 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.
- Migrations 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.
- A 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.
- The 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.
The deploy does not add polish to the project. It exposes whether the project was ever real.
Making the demo survive the viva
- Seeded 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.
- A 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.
- A 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.
- A 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.
- A 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