Back to Blog
Tutorial5 min read

How to Deploy a Project on a VPS From Zero (With a Domain and HTTPS)

DT
DomainDock Team
Engineering ·

Platforms that deploy your code for you are great until you have five projects, a database and a background worker. Then the bill has five line items and the free tiers go to sleep after a few minutes of no traffic.

A VPS is the other way. One small server runs everything and costs the same every month. This guide goes from an empty server to https://myapp.digitalweb.host serving your project, with the steps people usually skip.

We'll use Ubuntu 24.04 and Docker. The commands work on any recent Debian or Ubuntu.

Step 1: Pick a server

Any provider with a public IPv4 address works. Hetzner, DigitalOcean, Vultr, Linode, AWS Lightsail and Oracle Cloud are common picks. For a first project:

  • 1 vCPU and 1 GB RAM runs a couple of small APIs or a static site plus an API.
  • 2 GB RAM gives you room for a database next to the app.
  • A region close to your users. Latency matters more than CPU for most web apps.

When you create the server, add your SSH public key instead of a password. If you don't have one yet:

ssh-keygen -t ed25519 -C "you@example.com"
cat ~/.ssh/id_ed25519.pub

Step 2: Lock it down before anything else

Log in as root once, create a normal user and give it sudo:

ssh root@203.0.113.10
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

Then turn off password logins and root login over SSH. In /etc/ssh/sshd_config set:

PasswordAuthentication no
PermitRootLogin no

Restart SSH with sudo systemctl restart ssh and test a new login as deploy from a second terminal before you close the first one. Locking yourself out of a fresh server is a rite of passage you can skip.

Turn on automatic security updates:

sudo apt update && sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

Step 3: Install Docker

Docker's convenience script is the quickest route on a fresh server:

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker deploy

Log out and back in so the group change applies. docker run hello-world should print a greeting.

Step 4: Run your app

A compose.yaml in your project folder keeps the setup in one file you can commit:

services:
  app:
    image: ghcr.io/you/myapp:latest
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DATABASE_URL: ${DATABASE_URL}

Start it with docker compose up -d and watch the logs with docker compose logs -f app.

Two details cause most "it works locally" problems:

Listen on 0.0.0.0. Inside a container, an app bound to 127.0.0.1 can't be reached from outside the container at all. Most frameworks have a host flag: next start -H 0.0.0.0, uvicorn main:app --host 0.0.0.0, rails s -b 0.0.0.0.

Build somewhere else if memory is tight. A Next.js build can need more than 1 GB of RAM. Build the image in GitHub Actions, push it to a registry and only docker compose pull on the server.

Step 5: Open the port (twice)

There are usually two firewalls between your app and the internet.

The provider firewall. AWS calls it a security group. DigitalOcean and Hetzner call it a cloud firewall. Allow TCP on your app's port (3000 here) from anywhere.

The server firewall. If you use ufw:

sudo ufw allow OpenSSH
sudo ufw allow 3000/tcp
sudo ufw enable

One surprise worth knowing: ports published by Docker skip ufw rules, because Docker writes its own iptables rules. A container published on 0.0.0.0:5432 is open to the world even if ufw says otherwise. Publish databases on 127.0.0.1:5432:5432 or don't publish them at all.

Step 6: Test from the outside

From your own laptop, not the server:

curl -I http://203.0.113.10:3000

An HTTP status line (200, 302, even 404) means the app is reachable. A timeout means a firewall. "Connection refused" means nothing is listening on that port, or the app is bound to localhost.

Step 7: Give it a domain and HTTPS

Right now your project lives at http://203.0.113.10:3000. You can't send that to anyone.

The traditional fix is to buy a domain, point an A record at the server, install Nginx or Caddy on ports 80 and 443, write a config per app and set up certificates with certbot. That's a good thing to learn once. It is also an hour per server and more moving parts to keep patched.

The short way is DomainDock:

  1. Sign in and create a project.
  2. Add a domain, for example myapp, which becomes myapp.digitalweb.host.
  3. Enter 203.0.113.10 and port 3000.

The connection test runs right away. If the port is closed or the app redirects somewhere unexpected, it tells you before anything is created. When it passes, the route goes live in about a minute with a valid certificate. Your server keeps speaking plain HTTP on port 3000. The gateway handles HTTPS.

Running a second project on the same server? Give it port 3001 and its own address. You don't need to touch a proxy config.

Step 8: Keep it alive

A deploy isn't done when it loads once. A few habits save you later:

  • Restart policy. restart: unless-stopped brings containers back after a crash or a reboot.
  • Logs that don't fill the disk. Docker's default log driver grows forever. Add "log-opts": {"max-size": "10m", "max-file": "3"} to /etc/docker/daemon.json.
  • Backups. Snapshot the server weekly at your provider and dump the database somewhere off the box.
  • Monitoring. You want to hear about downtime from a check, not from a user. DomainDock checks every route every 5 minutes and can email you when your server stays down, plus a second email when it's back.

Updating the app

With images built in CI, an update is two commands:

docker compose pull
docker compose up -d

Compose only recreates the containers whose image changed. Add that to a GitHub Actions job that SSHes into the server after the image is pushed and you have push-to-deploy on a $5 server.

Wrap-up checklist

  • SSH keys only, no root login, automatic security updates
  • App listens on 0.0.0.0 and restarts on its own
  • Port open in the provider firewall and on the server
  • Databases not published to the internet
  • A real domain with HTTPS in front
  • Backups and an uptime check

That's a production deploy on hardware you control. When you're ready for the domain part, point your server at a free address.

Frequently asked questions

How much RAM does a VPS need to deploy a small project?

1 GB is enough for one or two small Node, Python or Go apps. Builds are the hungry part. If a Next.js or webpack build runs out of memory on the server, build the image in CI and only pull it on the VPS.

Why does my app work on the server but not from my browser?

Usually one of three things: the app listens on 127.0.0.1 instead of 0.0.0.0, the port is closed in the cloud provider's firewall or the server firewall blocks it.

Do I need Nginx to deploy a project on a server?

You need something on ports 80 and 443 that forwards requests to your app and handles HTTPS. That can be Nginx, Caddy or Traefik on the server. A gateway in front of it, such as DomainDock, works too.

Is a VPS cheaper than a platform like Vercel or Render?

For several always-on apps, usually yes: one VPS for a few dollars a month can run them all. You pay with your own time for updates, backups and monitoring.

deploymentVPSDockertutorialside project
Newsletter

Developer infrastructure, in your inbox

New guides on domains, SSL certificates and deploying to your own server. A couple of emails a month at most.

No spam. Every email has a one-click unsubscribe.