Code, Explained

Secure GitHub Actions Deployment to Ubuntu Using a Dedicated Deploy User

Automating deployment from GitHub to an Ubuntu server doesn’t need to mean giving GitHub access to root.

A better approach is to create a dedicated deploy user, allow GitHub Actions to SSH into the server as that user, and then give deploy permission to execute only one specific deployment script as another user.

This post walks through that setup.


Architecture

The deployment flow will look like this:

Developer
    │
    │ git push
    ▼
GitHub Repository
    │
    │ GitHub Actions
    ▼
GitHub Actions Runner
    │
    │ SSH
    ▼
deploy user
    │
    │ sudo -u dockeruser
    │ NOPASSWD
    ▼
dockeruser
    │
    │ execute deployment script
    ▼
/var/www/example.com
    │
    │ git pull
    ▼
Latest Code

The important security principle is:

GitHub should not need root access to the production server.


1. Add GitHub Actions

GitHub Actions workflows are stored inside:

.github/workflows/

Create a workflow file in your repository:

.github/workflows/deploy.yml

For example:

name: Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@YOUR_PINNED_COMMIT_SHA
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

Every time code is pushed to the main branch, GitHub Actions connects to the server and executes:

sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

GitHub Secrets

Go to:

Repository
→ Settings
→ Secrets and variables
→ Actions

Create these secrets:

SERVER_HOST
SERVER_SSH_KEY
SERVER_FINGERPRINT

SERVER_HOST

Your server hostname or IP address.

SERVER_SSH_KEY

Store the complete private SSH key, including:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Do not remove the line breaks.

The corresponding public key will be installed on the server for the deploy user.

SERVER_FINGERPRINT

This is the SSH server’s host-key fingerprint.

On Ubuntu, you can obtain it with:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256

It will look similar to:

256 SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx server (ED25519)

Store the SHA256:... portion in:

SERVER_FINGERPRINT

Using the fingerprint prevents the deployment process from blindly trusting an unknown SSH server.


2. Create a Dedicated Deploy User on Ubuntu

Instead of allowing GitHub Actions to connect as root, create a dedicated user:

sudo adduser deploy

If you don’t need an interactive shell, you can still use /bin/bash for troubleshooting and later restrict access further.

Create the SSH directory:

sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh

Add the public key:

sudo nano /home/deploy/.ssh/authorized_keys

Paste the public key corresponding to the private key stored in:

SERVER_SSH_KEY

Then set ownership and permissions:

sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys

Test the connection:

ssh deploy@your-server

If everything is configured correctly, GitHub Actions can authenticate as:

deploy

without using a root account.


3. Create the Deployment Script

Now create a script that performs the actual Git operation.

Create:

sudo nano /usr/local/bin/git-pull-by-dockeruser

Example:

#!/bin/bash

set -e

PROJECT="/var/www/example.com"

cd "$PROJECT"

echo "Running Git pull as: $(whoami)"

git pull origin main

echo "Deployment completed."

Make the script executable:

sudo chmod 755 /usr/local/bin/git-pull-by-dockeruser

Most importantly, make it owned by root:

sudo chown root:root /usr/local/bin/git-pull-by-dockeruser

This prevents the deploy user from modifying the script.


4. Allow Deploy to Run Only This Script as dockeruser

The Git operation needs to run as:

dockeruser

rather than:

deploy

We can use sudo for this.

Create a dedicated sudoers configuration:

sudo visudo -f /etc/sudoers.d/deploy-git

Add:

deploy ALL=(dockeruser) NOPASSWD: /usr/local/bin/git-pull-by-dockeruser

This means:

The deploy user may execute /usr/local/bin/git-pull-by-dockeruser as dockeruser without entering a password.

It does not give deploy unrestricted sudo access.

For example, this is intentionally avoided:

deploy ALL=(ALL) NOPASSWD: ALL

That would give the deployment account far more privileges than necessary.


5. Test the Sudo Permission

Switch to the deployment user:

sudo -iu deploy

Then execute:

sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

It should run without asking for a password.

You can also check the allowed sudo commands:

sudo -l

You should see something similar to:

(dockeruser) NOPASSWD: /usr/local/bin/git-pull-by-dockeruser

6. Make Sure dockeruser Can Write to the Git Repository

Because the Git command runs as dockeruser, the repository must be writable by that user.

For example:

sudo chown -R dockeruser:www-data /var/www/example.com

The important part is that dockeruser must be able to write to:

/var/www/example.com/.git/

especially:

.git/objects
.git/refs

Otherwise git pull can fail with an error such as:

error: insufficient permission for adding an object to repository database .git/objects
fatal: failed to write object
fatal: unpack-objects failed

Test directly:

sudo -u dockeruser git -C /var/www/example.com pull origin main

If that works, the GitHub Actions deployment should also work.


7. GitHub Actions Deployment

Once everything is configured, the GitHub Actions workflow only needs to execute:

script: |
  sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

The complete flow is:

git push
   │
   ▼
GitHub
   │
   ▼
GitHub Actions
   │
   │ SSH using SERVER_SSH_KEY
   ▼
deploy
   │
   │ sudo -u dockeruser
   │ NOPASSWD
   ▼
dockeruser
   │
   ▼
git-pull-by-dockeruser
   │
   ▼
git pull origin main

8. Why Use Two Different Users?

Using separate users provides a useful security boundary.

deploy

The deploy user exists primarily for:

GitHub Actions → Server

It doesn’t need to be the owner of the application or have unrestricted server privileges.

dockeruser

The dockeruser account is responsible for:

Git repository operations

and can own the application files if that fits the server architecture.

root

Root owns the deployment script and controls the sudo permission.

This gives you:

GitHub
  ↓
deploy
  ↓
specific sudo command
  ↓
dockeruser

instead of:

GitHub
  ↓
root

The second architecture should generally be avoided.


9. Additional Security Recommendations

Don’t use root for GitHub SSH

Avoid:

username: root

Use:

username: deploy

instead.


Don’t give deploy unrestricted sudo

Avoid:

deploy ALL=(ALL) NOPASSWD: ALL

Use:

deploy ALL=(dockeruser) NOPASSWD: /usr/local/bin/git-pull-by-dockeruser

Protect the deployment script

The deployment script should be:

root:root
755

For example:

sudo chown root:root /usr/local/bin/git-pull-by-dockeruser
sudo chmod 755 /usr/local/bin/git-pull-by-dockeruser

The deploy user should not be able to modify it.


Don’t disable SSH host verification

Avoid configurations that effectively disable host verification, such as:

StrictHostKeyChecking=no

Use the server fingerprint instead:

fingerprint: ${{ secrets.SERVER_FINGERPRINT }}

Pin third-party GitHub Actions

If using:

uses: appleboy/ssh-action

don’t blindly use a moving branch such as:

uses: appleboy/ssh-action@master

For production, pin the Action to a specific commit SHA. This reduces the risk of a future change to the Action unexpectedly changing what runs in your deployment pipeline.


10. Final Recommended Setup

For a simple Ubuntu production server, this is a good structure:

GitHub Repository
       │
       │ push to main
       ▼
GitHub Actions
       │
       │ SSH
       ▼
    deploy
       │
       │ sudo
       │ NOPASSWD
       ▼
  dockeruser
       │
       ▼
git-pull-by-dockeruser
       │
       ▼
/var/www/example.com

The key principle is least privilege:

  • GitHub gets an SSH key only for deploy.
  • deploy does not get root access.
  • deploy can sudo only to dockeruser.
  • deploy can run only one specific script through sudo.
  • The deployment script is owned by root.
  • dockeruser owns/writes the Git repository.
  • SSH host verification uses a known server fingerprint.

This provides a relatively simple deployment system while keeping the privileges of the GitHub Actions credential tightly constrained.

Posted in git, linux, ubuntuTagged , , , , , ,

How to Build a Docker SMTP Relay on Ubuntu Using Postfix

If your applications need to send emails reliably, an SMTP relay is one of the cleanest solutions.

In this tutorial, we will build a lightweight SMTP relay using Docker and Postfix on Ubuntu. Your applications will send email locally to the relay, and the relay will securely forward mail through providers like Amazon SES, SendGrid, Mailgun, or Gmail SMTP.

This setup is ideal for:

  • Laravel applications
  • WordPress websites
  • Node.js apps
  • Dockerized services
  • Internal notification systems
  • Transactional emails

Architecture

Application
    ↓ SMTP
Docker Postfix Relay
    ↓ TLS SMTP
Amazon SES / SendGrid / Mailgun
    ↓
Recipient Inbox

Prerequisites

Before starting, make sure you have:

  • Ubuntu server
  • Docker installed
  • Docker Compose plugin installed
  • SMTP provider credentials

Supported providers include:

  • Amazon SES
  • SendGrid
  • Mailgun
  • Gmail SMTP
  • Postmark

Step 1 — Install Docker

Update Ubuntu:

sudo apt update

Install Docker:

sudo apt install -y docker.io docker-compose-plugin

Enable Docker:

sudo systemctl enable --now docker

Verify installation:

docker --version

Optional: run Docker without sudo

sudo usermod -aG docker $USER
newgrp docker

Step 2 — Create Project Directory

Create a working directory:

sudo mkdir -p /opt/smtp-relay
cd /opt/smtp-relay

Step 3 — Create Persistent Storage

Create directories for mail queue and logs:

sudo mkdir -p relay
sudo mkdir -p logs

These directories ensure queued emails survive container restarts.


Step 4 — Create Docker Compose File

Create a docker-compose.yml file:


services:
  smtp-relay:
    image: boky/postfix
    container_name: smtp-relay
    restart: unless-stopped

    ports:
      - "25:25"

    environment:
      # Upstream SMTP provider
      RELAYHOST: smtp.gmail.com
      RELAYHOST_PORT: 587
      RELAYHOST_USERNAME: YourSMTPEnabledGmailUserID
      RELAYHOST_PASSWORD: YourGmailPassword

      # Allowed sender domains
      ALLOWED_SENDER_DOMAINS: wempro.com,pumpsandinstrumentations.com

      # Relay hostname
      POSTFIX_myhostname: relay.vmi3202307.local
      POSTFIX_mynetworks: 127.0.0.0/8 172.16.0.0/12 192.168.0.0/16

      POSTFIX_smtpd_recipient_restrictions: permit_mynetworks,reject_unauth_destination

      TZ: UTC

    volumes:
      # Mail queue persistence
      - ./relay:/var/spool/postfix

      # Optional logs
      - ./logs:/var/log

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"



Save the file.


Step 5 — Start the SMTP Relay

Launch the container:

docker compose up -d

Verify container status:

docker ps

View logs:

docker logs -f smtp-relay

Step 6 — Test Email Sending

Install swaks:

sudo apt install -y swaks

Send a test email:

swaks \
  --to you@example.com \
  --from noreply@yourdomain.com \
  --server localhost:25 \
  --header "Subject: SMTP Relay Test" \
  --body "SMTP relay is working"

Successful output:

250 2.0.0 Ok: queued as ...

Step 7 — Configure Your Application

Your applications should connect to:

localhost:25

Example DSN:

smtp://localhost:25

Laravel .env example:

MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=25
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="Your App"

Multiple Domain Support

To allow multiple sender domains:

ALLOWED_SENDER_DOMAINS: domain1.com,domain2.com,domain3.com

Why Use an SMTP Relay?

Benefits include:

  • centralized email handling
  • provider abstraction
  • email queueing
  • retry handling
  • cleaner application configuration
  • rate limiting
  • easier provider switching

Important Security Tips

Do NOT Create an Open Relay

Never use:

POSTFIX_mynetworks: 0.0.0.0/0

This will allow the internet to abuse your server for spam.


SPF, DKIM, and Deliverability

For production use, verify your domain with your SMTP provider and configure:

  • SPF
  • DKIM
  • DMARC

Without these, emails may land in spam folders.


Queue Management

View mail queue:

docker exec -it smtp-relay postqueue -p

Flush queue:

docker exec -it smtp-relay postqueue -f

Final Thoughts

A Dockerized SMTP relay is a lightweight and reliable solution for modern applications. By combining Postfix with providers like Amazon SES or SendGrid, you get:

  • reliable delivery
  • secure outbound SMTP
  • local application integration
  • retry and queue management
  • simplified infrastructure

This setup works especially well for Docker-based deployments and internal application stacks.

Happy emailing!

Posted in ubuntuTagged , , , , , ,

Jailed Ubuntu SFTP User

> Add a user as system user (which will prevent to create home directory) but without login capability

$ sudo adduser moderpatshala --system --shell /usr/sbin/nologin

>> That user need a password to login, you can skip it if you want to use public key authentication which is more secured than password login
$ sudo passwd moderpatshala
>> Now fix jail directory as root owned
$ sudo chown root:root /home/moderpatshala
>> Fix permission, chroot required relax file mode for root location like drwxr-xr-x
$ sudo chmod 755 /home/moderpatshala
>> Provide a writable directory under jailed directory for your sftp user
$ sudo chown -R moderpatshala /home/moderpatshala/public_html

>> Now you need to change SSH demon settings. You can add (if your sshd configuration settings allowed) a different file which I prefer
$ sudo vi /etc/ssh/sshd_config
————- or ———————-
$ sudo vi /etc/ssh/sshd_config.d/80-user-moderpatshala.conf

Match User moderpatshala
  PasswordAuthentication yes
  PubkeyAuthentication no
  ChrootDirectory /home/moderpatshala
  ForceCommand internal-sftp
  X11Forwarding no
  AllowTcpForwarding no

>> Finally it’s time to restart your sshd
$ sudo systemctl restart ssh

Posted in linux, ubuntuTagged , ,

Create Docker Container for Hello World with Django and uWsgi Server

I was searching a Hello World implementation for Django of Python in Docker container, but can’t find any good resource at online. So, I plan to code it myself and document it.

This is pure Docker implementation, you don’t need to create any project for Django. You just need Dockerfile to see “Hello World” at browser which powered by Django and uWsgi module.

Here is high level explanation that I’m going TODO –

  • Python, Pip and setuptools installation and upgrade
  • Create requirement.txt file
  • Execute requirement.txt file with Pip
  • Create Django project
  • Modify project settings to allow our domain in Django
  • Replace project’s urls.py to send “Hello World” string to output
  • Code to run server through uWsgi module

Entire steps I’ll do into a single Dockerfile, which we need to build and run through Docker. Here is step by step implementation of Dockerfile.

FROM python:3.11.3
WORKDIR /code
RUN pip install --upgrade pip
RUN pip install setuptools
RUN pip install -U setuptools

Its pretty straight forward, we are using Python 3.11.3 and install Pip and setuptools here.

RUN echo "Django==4.2" >> requirements.txt
RUN echo "uWSGI==2.0.25" >> requirements.txt
RUN pip install -r requirements.txt

Here we create requirement.txt file where we instruct to install Django version 4.2 and uWSGI module version 2.0.25 and then we execute the newly created requirements.txt through Pip.

RUN django-admin startproject helloworlddjango
WORKDIR /code/helloworlddjango
RUN echo "ALLOWED_HOSTS = ['127.0.0.1', 'localhost', 'helpabodessltest.shahadathossain.com']" >> helloworlddjango/settings.py

In this stage we created helloworld project with django-admin (we already Django installed) also we append our project’s settings.py to allow our domain. For this we just append “ALLOWED_HOSTS” variable value.

RUN echo "from django.urls import path" > helloworlddjango/urls.py
RUN echo "from django.shortcuts import HttpResponse" >> helloworlddjango/urls.py
RUN echo "def home_page_view_hello_world(request):" >> helloworlddjango/urls.py
RUN echo "    return HttpResponse('Hello World')" >> helloworlddjango/urls.py
RUN echo "urlpatterns = [path('', home_page_view_hello_world, name='helloworld'),]" >> helloworlddjango/urls.py

This part actually pure Python code we (re)writing our urls.py file where we actually put “Hello World” string when user visit home page of our project.

RUN adduser --disabled-password --no-create-home django
USER django
ENTRYPOINT ["uwsgi", "--http", ":9000", "--workers", "4", "--master", "--enable-threads", "--module", "helloworlddjango.wsgi"]

This is another part where we run our project through uwsgi module. We can run straightly by Django’s builtin server by “manage.py” but here I covered to run uwsgi server.

Here is link https://github.com/razonklnbd/django-hello-world-with-docker where you found complete Dockerfile

To build docker container you have to have docker in your system. After ensuring docker into system you can use following commands to build and run –

sudo docker build -t django-hello-world-mshk .
sudo docker run --name djangohelloworldmshk -d --network=host django-hello-world-mshk:latest

You need to execute into the location where you put your Dockerfile. Please feel free to change container tag and name. You may like following command of docker to see the log and to delete running container (in case you are debugging something)

sudo docker logs djangohelloworldmshk
sudo docker rm $(sudo docker stop $(sudo docker ps -a -q --filter ancestor=django-hello-world-mshk --format="{{.ID}}"))
sudo docker rmi django-hello-world-mshk

That’s all for today! Thanks.

Posted in linux, Python, webdevelopmentTagged , , , ,

Install secured Proftpd w/o database w/ virtual jailed user

Recently I need to install simple ftp server to provide access. I used Proftpd which is I believe is good (I used in small project). When I starting install, I faced some technical problem and overcome it. So, I think I should write my experience for my personal future reference.

  1. Install proftpd-basic (follow https://mtxserv.com/vps-server/doc/how-to-install-a-ftp-server-with-proftpd-debian-ubuntu or any other good document available by searching internet)
    1.a) Configure to use virtual user
    1.b) Add virtual user using “ftpasswd” command
  2. Configure jail option of proftpd configuration (read – https://portal.hostingcontroller.com/kb/a222/how-to-jail-ftp-users-using-proftpd-server.aspx)
    Remove # (uncomment) in front of below line
    DefaultRoot ~
  3. Configure passive ports
    3.a) At firewall allow 20, 21 and those passive ports (example below)
    ufw allow 49xxx:49999/tcp
    ufw reload
  4. Restart proftpd

-> Test ftp connection

Secure ftp connection with self-signed TLS:

  1. Follow TLS configuration part only from https://www.makeuseof.com/install-proftpd-on-ubuntu/ or any other good document available to configure TLS
  2. Replace “TLSProtocol” settings (follow https://serverfault.com/a/1023382)
    TLSProtocol TLSv1 TLSv1.1 TLSv1.2
  3. Restart proftpd

Now test using FTP client, you may see that host name different than server. As because we used self-signed this type of warning we can ignore.

Posted in linux, ubuntuTagged , , , ,

Apache Python3 Gunicorn

My journey to install Gunicorn to server Python project is not pleasant because of old Ubuntu system where Python version 3.5 installed but default Gunicorn not compatible with this version.

So, as suggested from gunicorn.org I need to install Gunicorn version 3 for Python 3 … The point is, I need to install this Gunicorn 3 at outside of my virtual environment.

First of all we need to change wsgi.py file in Python project in my case – “<project root>/helloworld/wsgi.py”

import os, sys
# add the hellodjango project path into the sys.path
sys.path.append('/home/django-helloworld/helloworld')

# add the virtualenv site-packages path to the sys.path
sys.path.append('/home/django-helloworld/myvenv/lib/python3.5/site-packages')

from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'helloworld.settings')
application = get_wsgi_application()

Above file I added two lines because while executing from gunicorn 3 (which installed outside of virtual environment i.e. into OS) Python can’t find Django or other project related package.

Posted in apache, linux, PythonTagged , , , ,

Execute React JS in ntfs partition

I do development in mounted hard drive which is different than OS partition, also I like to use that mounted drive within different OS like windows and linux simultaneously. Which allow me portability of my code backup in different system.

Challenge is, I have to use such file system for that mounted drive which can accessible in most of the OS like windows, linux or iOS. And in this case NTFS is the best file system which is really portable. Problem for this FS is, it can’t support executable bit of linux which lead to raise many problem in React development. Like if you put any React project into that mounted drive, you can’t execute that code.

To solve this problem I take Docker as a solution. Simply, I create docker image of React project and execute that image. Here is the simple Dockerfile for React project –

FROM node:18
WORKDIR /app
COPY package.json ./
RUN npm install
RUN npm install -g npm@9.7.2
COPY . ./
EXPOSE 3000
CMD ["npm", "start"]

You just need to build this docker image and need to run. You can see docker log for any output for your React project.

Posted in ubuntu, windowsTagged , , , ,

Copy large amount of file to remote server using nohup tar and ssh

This command will copy large amount of file to remote server by compressing and decompressing on the fly. It saves time and bandwidth. It will execute in background, so you can detach current login session.

nohup sh -c “tar -c /any/directory/at/source/server/ | gzip -2 | ssh server-alias ‘cat | tar xz -C /target/directory/of/target/server/'” > /dev/null 2>&1 &

here nohup output sent to /dev/null that means i don’t want any nohup output. you can adjust its behavior.

I use the command for millions of file that occupied more than 500 GB.

Posted in linuxTagged , , ,

add new hard disk into ubuntu more than 2TB size

lsblk

will list available device

parted /dev/sdd

considered device “sdd” from lsblk output

(parted) mklabel gpt
(parted) mkpart primary ext3 0 100%
(parted) print
(parted) quit

mkfs.ext3 /dev/sdd1

this command will format this hard disk into ext3 file system as we instruct by parted command

mkdir /home/data

mounting point directory creating

mount -t ext3 /dev/sdd1 /home/data

mounting formatted hard disk into target directory

blkid

try to find ID of new hard disk to write into fstab so that after restart our hard disk will mount automatically

sample output of blkid

/dev/sdd1: UUID="20e4b16b-4d4c-4053-b6f4-a2c103f2db2f" TYPE="ext3" PARTLABEL="primary" PARTUUID="33658d68-726f-4398-992f-2aaafebe17ff"

vi /etc/fstab

give an entry for our new hard disk at the end of this file

example entry

UUID=20e4b16b-4d4c-4053-b6f4-a2c103f2db2f /home/data ext3 nofail 0 0
Posted in linux, ubuntuTagged , , , ,

recursive change group and file mode in linux and detach active login session

change group

nohup sh -c “find /any/path/that/need/to/change/* -group mygroup -exec chgrp www-data {} \;” > /dev/null 2>&1 &

traditional way

nohup sh -c “chgrp -R www-data /any/path/that/need/to/change” > /dev/null 2>&1 &

change mode

nohup sh -c “find /any/path/that/need/to/change/* -perm u=rw,g=r,o=r -execdir chmod g+w {} \;” > /dev/null 2>&1 &

traditional way

nohup sh -c “chmod -R g+w /any/path/that/need/to/change” > /dev/null 2>&1 &

Posted in linux, ubuntuTagged , , , , , ,