Goal: Create a repo that can deploy/update an n8n stack (with Caddy proxy and Watchtower) to an existing Ubuntu 24.04 server via GitHub Actions, using Ansible provisioning and SOPS (age)-encrypted secrets committed to the repo.
- Commit this project skeleton.
- Generate an age keypair; commit the public key into
.sops.yaml. - Encrypt
.env.sopswith SOPS (no plaintext secrets in Git). - Add GitHub Secrets:
AGE_PRIVATE_KEY,SSH_PRIVATE_KEY,SSH_HOST,SSH_USER(and optionalSSH_PORT). - Push to
main→ CI runs Ansible → Docker Compose up → n8n available at your domain.
-
You already have:
- A reachable Ubuntu 24.04 server (public IP).
- SSH access using a private key (add this as a GitHub Secret).
- A DNS record pointing your chosen hostname (e.g.,
n8n.example.org) to the server (optional but recommended). - A deploy user with passwordless sudo access configured (see Server Setup below).
-
The repo will:
- Optimize system performance for tiny Ubuntu VMs (ZRAM/swap, kernel tuning, systemd optimization),
- Install Docker Engine + Compose plugin,
- Place Caddyfile, docker-compose.yml, and .env (from SOPS),
- Start
caddy,n8n,watchtower.
Create these files exactly:
.
├── ansible/
│ ├── inventory.ini # generated by CI (templated from GH secrets)
│ └── playbook.yml
├── app/
│ ├── Caddyfile
│ └── workflows/ # (optional) store exported n8n JSON here
├── ansible/roles/
│ ├── n8n/
│ │ ├── files/
│ │ │ └── docker-compose.yml
│ │ └── templates/
│ │ └── .env.sops # SOPS-encrypted .env (commit encrypted only)
│ └── performance_optimization/ # System performance optimization for tiny VMs
│ ├── tasks/
│ ├── handlers/
│ ├── templates/
│ └── defaults/
├── .github/workflows/
│ └── deploy.yml
├── .sops.yaml
└── README.md # this file
- hosts: n8n
become: true
gather_facts: true
pre_tasks:
- name: Ensure apt cache is fresh
apt:
update_cache: true
changed_when: false
tasks:
- name: Install base packages
apt:
name:
- curl
- git
- ufw
- ca-certificates
- gnupg
state: present
- name: Configure UFW (allow SSH, HTTP, HTTPS)
ufw:
state: enabled
policy: deny
notify: Reload UFW
- ufw:
rule: allow
name: OpenSSH
- ufw:
rule: allow
port: "80"
proto: tcp
- ufw:
rule: allow
port: "443"
proto: tcp
- name: Install Docker Engine + Compose plugin
shell: |
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $UBUNTU_CODENAME stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
args: { executable: /bin/bash }
- name: Create app directory
file:
path: /opt/n8n
state: directory
mode: "0755"
- name: Create local_files directory (for n8n mounts)
file:
path: /opt/n8n/local_files
state: directory
mode: "0755"
- name: Place docker-compose.yml
copy:
src: roles/n8n/files/docker-compose.yml
dest: /opt/n8n/docker-compose.yml
mode: "0644"
- name: Place Caddyfile
copy:
src: ../app/Caddyfile
dest: /opt/n8n/Caddyfile
mode: "0644"
- name: Decrypt .env.sops (SOPS)
community.sops.sops_decrypt:
path: roles/n8n/templates/.env.sops
register: envfile
- name: Write .env
copy:
content: "{{ envfile.content }}"
dest: /opt/n8n/.env
mode: "0600"
- name: Start/Update stack
community.docker.docker_compose_v2:
project_src: /opt/n8n
state: present
handlers:
- name: Reload UFW
command: ufw reloadservices:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- n8n
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
env_file: .env
environment:
- N8N_PROTOCOL=https
- N8N_SECURE_COOKIE=true
- NODE_ENV=production
- GENERIC_TIMEZONE=America/New_York
volumes:
- n8n_data:/home/node/.n8n
- ./local_files:/files
watchtower:
image: containrrr/watchtower
restart: unless-stopped
command: --cleanup --interval 3600 caddy n8n
volumes:
- /var/run/docker.sock:/var/run/docker.sock
volumes:
caddy_data:
caddy_config:
n8n_data:n8n.example.org {
encode gzip
reverse_proxy n8n:5678
}
creation_rules:
- path_regex: ansible/roles/n8n/templates/\.env\.sops$
key_groups:
- age:
- age1replace_this_with_your_public_keyExample plaintext (encrypt with SOPS before commit):
N8N_HOST=n8n.example.org
N8N_PORT=5678
N8N_ENCRYPTION_KEY=use-a-32-64-char-random-string
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=trey
N8N_BASIC_AUTH_PASSWORD=long-random-password
Encrypt:
sops --encrypt --in-place ansible/roles/n8n/templates/.env.sopsname: Deploy n8n to Existing Server
on:
push:
branches: [ "main" ]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Ansible + collections
run: |
python -m pip install --upgrade pip
pip install ansible
ansible-galaxy collection install community.docker community.sops
- name: Install age and sops
run: |
sudo apt-get update
sudo apt-get install -y age
# Install sops from GitHub releases
curl -LO https://github.com/getsops/sops/releases/download/v3.10.2/sops-v3.10.2.linux.amd64
sudo mv sops-v3.10.2.linux.amd64 /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops
# Verify installation
sops --version
- name: Configure SOPS age key
run: |
mkdir -p ~/.config/sops/age
echo "${{ secrets.AGE_PRIVATE_KEY }}" > ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
- name: Start ssh-agent and add deploy key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: |
${{ secrets.SSH_PRIVATE_KEY }}
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -p "${{ secrets.SSH_PORT || '22' }}" "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
- name: Write Ansible inventory from secrets
run: |
echo "[n8n]" > ansible/inventory.ini
echo "server ansible_host=${{ secrets.SSH_HOST }} ansible_user=${{ secrets.SSH_USER }} ansible_port=${{ secrets.SSH_PORT || '22' }}" >> ansible/inventory.ini
- name: Run Ansible playbook
working-directory: ansible
env:
ANSIBLE_HOST_KEY_CHECKING: "False"
run: |
ansible-playbook -i inventory.ini playbook.yml -vSSH_HOST: server IP or DNSSSH_USER: SSH user (e.g., root or deploy)SSH_PORT: optional (default 22)SSH_PRIVATE_KEY: private key contentsAGE_PRIVATE_KEY: age private key (for decrypting.env.sops)
Before running the deployment, ensure your Ubuntu 24.04 server is properly configured:
# Run these commands as root on your server
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy# Allow deploy user to use sudo without password
echo "deploy ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
# Verify configuration
sudo visudo -c# Create SSH directory for deploy user
mkdir -p /home/deploy/.ssh
# Add your public key (replace with your actual public key)
echo "your-ssh-public-key-here" > /home/deploy/.ssh/authorized_keys
# Set proper permissions
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keysEdit /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AllowUsers deployThen restart SSH:
systemctl restart sshd# Test from your local machine
ssh deploy@your-server-ip "sudo whoami"
# Should return "root" without password promptRun the automated setup script:
# Install age from Ubuntu repositories
sudo apt-get update
sudo apt-get install age
# Install sops from GitHub releases (not available in Ubuntu repos)
curl -LO https://github.com/getsops/sops/releases/download/v3.10.2/sops-v3.10.2.linux.amd64
sudo mv sops-v3.10.2.linux.amd64 /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops
# Run the setup script
./setup.shThe script will guide you through:
- Generating an age keypair
- Updating
.sops.yamlwith your public key - Encrypting the environment file
- Showing you what GitHub secrets to configure
# Install age if not already installed
sudo apt-get install age
# Generate keypair
age-keygen -o key.txt
# The public key will be displayed, copy it to .sops.yaml
# Keep the private key safe for GitHub secrets- Edit
app/Caddyfile- replacen8n.example.orgwith your domain - Edit
ansible/roles/n8n/templates/.env.sops- update the environment variables - Edit
.sops.yaml- replace the public key with your generated age public key
# Encrypt the environment file
sops --encrypt --in-place ansible/roles/n8n/templates/.env.sopsIn your GitHub repository settings, add these secrets:
SSH_HOST: Your server's IP address or hostnameSSH_USER: SSH username (e.g.,rootordeploy)SSH_PORT: SSH port (optional, defaults to 22)SSH_PRIVATE_KEY: Your SSH private key contentAGE_PRIVATE_KEY: Your age private key content (from key.txt)
git add .
git commit -m "Initial n8n deployment setup"
git push origin mainThe GitHub Action will automatically run and deploy n8n to your server.
git push origin mainAction runs, provisions server, deploys stack.
Visit https://n8n.example.org.
After successful deployment:
- Visit your n8n instance at your configured domain
- Complete the initial setup wizard
- Configure workflows as needed
- Export workflows to
app/workflows/directory for version control
To update the deployment:
- Make changes to configuration files
- If updating secrets, re-encrypt with SOPS
- Commit and push changes
- GitHub Actions will automatically redeploy
The playbook automatically includes performance optimizations for tiny Ubuntu VMs. This feature can be controlled with the enable_performance_optimization variable (default: true).
-
Swap Management
- ZRAM for VMs < 2GB RAM (compressed, in-memory)
- Swapfile for larger VMs (persistent, disk-based)
- Conservative swappiness settings (reduces swap usage)
-
Kernel/Network Tuning
- BBR TCP congestion control (if available)
- Optimized TCP buffer sizes and connection limits
- Fair queueing (fq) traffic scheduler
-
Systemd/Journal Optimization
- Limited journal size (100MB max) to prevent disk issues
- systemd-oomd for memory pressure handling
- Faster boot/shutdown timeouts
-
Filesystem/I/O
- Increased file descriptor limits (65536)
- Automatic I/O scheduler optimization
- Optional noatime mounts for read-heavy servers
To disable performance optimization, add to your playbook variables:
# In ansible/playbook.yml
vars:
enable_performance_optimization: falseTo run only performance optimization:
ansible-playbook -i inventory.ini playbook.yml --tags "performance"After deployment, verify optimizations:
# Test performance optimizations only
ansible-playbook -i inventory.ini test-performance.yml
# Or check manually on the server
swapon --show # Check swap configuration
sysctl net.ipv4.tcp_congestion_control # Check TCP optimization
ulimit -n # Check file descriptor limits
journalctl --disk-usage # Check journal sizeThe optimization is Ubuntu-specific and includes safety checks to prevent running on incompatible systems.
- Domain not resolving: Ensure DNS is properly configured
- SSL certificate issues: Caddy handles automatic HTTPS, ensure domain points to server
- Permission errors: Check that the deploy user has proper sudo access
- Docker issues: Verify Ubuntu 24.04 compatibility
Check deployment logs in GitHub Actions or SSH to server:
# Check container logs
docker logs n8n-n8n-1
docker logs n8n-caddy-1
# Check Docker Compose status
cd /opt/n8n
docker compose ps