Update VPS
We will need a Debian/Ubuntu VPS. First, let's connect to our server.
ssh root@server-ip-address # E.g., ssh root@1.2.3.4 And update the system.
apt update && apt -y upgrade SSH-key login
Now let's create a separate deploy user to run our app (using the root superuser is not the best idea).
adduser deploy usermod -aG sudo deploy A more secure way to log in to a server is using an SSH key. Let's create one on the "local" machine.
cd ~/.ssh ssh-keygen -t ed25519 -f vps-key And copy it to the server (use brew install ssh-copy-id to install ssh-copy-id on Mac).
ssh-copy-id -i vps-key deploy@server-ip-address Check that we can log in with the SSH key.
ssh -i ~/.ssh/vps-key deploy@server-ip-address Next, let's disable root and password login. Open the config file:
sudo nano /etc/ssh/sshd_config And set:
PermitRootLogin no PasswordAuthentication no Save the config file and restart the service.
sudo systemctl restart ssh Turn on Firewall (UFW)
Only allow SSH, HTTP, and HTTPS.
sudo apt -y install ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable sudo ufw status Install Docker
Use official Docker docs.
Add the deploy user to the docker group.
sudo usermod -aG docker $USER newgrp docker docker version Create App Docker Image
Before we can run our app with Docker on the server, we need to "pack" it into a Docker image. Go to your project folder on your local machine, and create a Dockerfile. Search for a Dockerfile for your language/framework. For example, a Node.js app Dockerfile could look something like this.
# syntax=docker/dockerfile:1 # Comments are provided throughout this file to help you get started. # If you need more help, visit the Dockerfile reference guide at # https://docs.docker.com/go/dockerfile-reference/ # This Dockerfile uses Docker Hardened Images (DHI) for enhanced security. # For more information, see https://docs.docker.com/dhi/ # Builder stage: install all dependencies and compile TypeScript. FROM dhi.io/node:24-alpine3.23-dev AS builder WORKDIR /app # Install dependencies as a separate step to take advantage of Docker's # caching. Leverage a cache mount to /root/.npm to speed up subsequent # builds. Leverage a bind mount to package.json to avoid having to copy # it into this layer. RUN --mount=type=cache,target=/root/.npm \ --mount=type=bind,source=package.json,target=package.json \ npm install # Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files: # RUN --mount=type=cache,target=/root/.npm \ # --mount=type=bind,source=package.json,target=package.json \ # --mount=type=bind,source=package-lock.json,target=package-lock.json \ # npm ci # Copy the source code into the container and compile TypeScript. COPY . . RUN npm run build # Deps stage: install production dependencies only. FROM dhi.io/node:24-alpine3.23-dev AS deps WORKDIR /app RUN --mount=type=cache,target=/root/.npm \ --mount=type=bind,source=package.json,target=package.json \ npm install --omit=dev # Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files: # RUN --mount=type=cache,target=/root/.npm \ # --mount=type=bind,source=package.json,target=package.json \ # --mount=type=bind,source=package-lock.json,target=package-lock.json \ # npm ci --omit=dev # Runner stage: minimal runtime image with compiled app and production deps. FROM dhi.io/node:24-alpine3.23 AS runner ENV PATH=/app/node_modules/.bin:$PATH WORKDIR /app COPY --from=deps --chown=node:node /app/node_modules ./node_modules COPY --from=builder --chown=node:node /app/dist ./dist # Expose the port that the application listens on. EXPOSE 3000 # Run the application. CMD ["node", "dist/index.js"] Now check that Docker is installed on your local machine, and build the app Docker image.
docker buildx build --platform linux/amd64 -t my-app:latest Notice the --platform linux/amd64 flag since we are building for our Linux server.
GitHub Container Registry
Great, we just created our app Docker image on our local machine. But how will we "copy" it to the server?
With Docker, we can push and pull images to the "image registry". Let's use GitHub Container Registry (ghcr.io).
Create a GitHub token to be able to push Docker images to ghcr.io
- Log in to github.com.
- Click on the "Profile Icon" (the top right corner) -> select
Settings. - Then
Credentials(on the left side) ->Personal access tokens (classic)(on the right side) - Create a new token with:
-
write:packages- push images -
read:packages- pull images -
delete:packages- optional
-
Login to ghcr.io
echo "your-github-token" | docker login ghcr.io -u your-github-username --password-stdin ⚠️ Change your-github-token and your-github-username to real values.
Rebuild the Image
We need to rebuild the app image using the proper image name.
docker buildx build --platform linux/amd64 -t ghcr.io/your-github-username/your-app-name:latest . ⚠️ Change your-github-username and your-app-name to real values.
Push the Image
docker push --all-tags ghcr.io/your-github-user-name/your-app-name Pull the Image on the VPS
Log in to the server.
ssh -i ~/.ssh/vps-key deploy@server-ip-address` Log in to ghcr.io.
echo "your-github-token" | docker login ghcr.io -u your-github-username --password-stdin docker pull ghcr.io/geshela/gesheru_bot:latest ⚠️ Change your-github-username and your-app-name to real values.
Run the App
docker run -d --name your-app-name --restart unless-stopped -p 127.0.0.1:3000:3000 ghcr.io/your-github-username/your-app-name ⚠️ Change your-github-username and your-app-name to real values.
Check if the app container is running.
docker ps docker logs -f --tail your-app-name P.S. To restart the app.
docker stop your-app-name docker rm your-app-name docker run -d --name your-app-name --restart unless-stopped -p 127.0.0.1:3000:3000 ghcr.io/your-github-username/your-app-name Use Nginx as a Reverse Proxy
Install Nginx.
sudo apt -y install nginx sudo systemctl enable --now nginx Turn off the default config.
sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t && sudo systemctl reload nginx Create a new config for the app.
sudo nano /etc/nginx/sites-available/your-app-name ⚠️ Change your-app-domain.com to the real value.
server { listen 80; server_name your-app-domain.com www.your-app-domain.com; client_max_body_size 25m; location / { proxy_pass http://127.0.0.1:8000; 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_read_timeout 60s; } } Let's enable the config.
sudo ln -s /etc/nginx/sites-available/your-app-name /etc/nginx/sites-enabled/your-app-name sudo nginx -t sudo systemctl reload nginx Set up the DNS A Record
This is necessary so that the domain knows which IP address it should be linked to.
Log in to your domain registrar's admin panel. Add the A record. Enter the IP address of your server.
Add HTTPS with Let's Encrypt
Install Certbot.
sudo apt -y install certbot python3-certbot-nginx Create the certificate.
sudo certbot --nginx -d your-app-domain.com -d www.your-app-domain.com That's it. The app is live now! 🥳
Credits
Photo by Kevin Ache on Unsplash
