# Update system
rm -f /var/lib/man-db/auto-update
apt update && apt upgrade -y
# Install essential packages
export DEBIAN_FRONTEND=noninteractive
apt install -y git vim htopUsing my preferred bash and vim config.
git clone https://github.com/lhermann/dotfiles.git ~/.dotfiles
ln -fs ~/.dotfiles/.bash_aliases ~/.bash_aliases
ln -fs ~/.dotfiles/.bash_env ~/.bash_env
ln -fs ~/.dotfiles/.bashrc ~/.bashrc
ln -fs ~/.dotfiles/.bash_profile ~/.bash_profile
ln -fs ~/.dotfiles/.vimrc ~/.vimrc
ln -fs ~/.dotfiles/.tmux.conf ~/.tmux.conf
ln -fs ~/.dotfiles/.gitconfig ~/.gitconfig
source ~/.bashrc# Add Docker's official GPG key
apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
# Add the repository to Apt sources
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable services
systemctl enable docker.service
systemctl enable containerd.service
systemctl start dockerfallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab
sysctl vm.swappiness=10
echo 'vm.swappiness=10' | tee -a /etc/sysctl.conf# Install nginx
apt install -y nginx
systemctl enable nginx.serviceExample nginx reverse-proxy for load-balanced node instances:
# Load Balancer
upstream backend_load_balanced {
hash $http_cf_connecting_ip consistent; # Sticky sessions based on client IP
server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3002 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3003 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3004 max_fails=3 fail_timeout=30s;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP Server (Port 80)
server {
listen 80;
listen [::]:80;
server_name <<ENTER_DOMAIN_HERE>>;
charset utf-8;
root /dev/null;
# Force the latest IE version
add_header "X-UA-Compatible" "IE=Edge";
# Prevent clients from accessing hidden files (starting with a dot)
location ~* /\.(?!well-known\/) {
deny all;
}
# Prevent clients from accessing to backup/config/source files
location ~* (?:\.(?:bak|conf|dist|fla|in[ci]|log|psd|sh|sql|sw[op])|~)$ {
deny all;
}
# Let's Encrypt challenge location (works for both HTTP and HTTPS)
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files $uri $uri/ =404;
}
location / {
proxy_pass http://backend_load_balanced;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Request $request;
proxy_set_header X-Real-URI $request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
# WebSocket specific settings
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_connect_timeout 86400;
}
}
# HTTPS Server (Port 443)
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name <<ENTER_DOMAIN_HERE>>;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/<<ENTER_DOMAIN_HERE>>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<<ENTER_DOMAIN_HERE>>/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
charset utf-8;
root /dev/null;
# Force the latest IE version
add_header "X-UA-Compatible" "IE=Edge";
# Prevent clients from accessing hidden files (starting with a dot)
location ~* /\.(?!well-known\/) {
deny all;
}
# Prevent clients from accessing to backup/config/source files
location ~* (?:\.(?:bak|conf|dist|fla|in[ci]|log|psd|sh|sql|sw[op])|~)$ {
deny all;
}
# Let's Encrypt challenge location (works for both HTTP and HTTPS)
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files $uri $uri/ =404;
}
# Health check endpoint
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
proxy_pass http://backend_load_balanced;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Request $request;
proxy_set_header X-Real-URI $request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
# WebSocket specific settings
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_connect_timeout 86400;
}
}Use Cloudflare and Google nameserver because I ran into problems with default Hetzner nameservers in the past.
mkdir -p /etc/resolvconf/resolv.conf.d/
echo 'nameserver 1.1.1.1' >> /etc/resolvconf/resolv.conf.d/head
echo 'nameserver 8.8.8.8' >> /etc/resolvconf/resolv.conf.d/head
# Apply changes
if command -v resolvconf &> /dev/null; then
resolvconf -u
else
echo 'nameserver 1.1.1.1' > /etc/resolv.conf
echo 'nameserver 8.8.8.8' >> /etc/resolv.conf
fi
# Verify DNS resolution
nslookup redis-11762.c1.us-east1-2.gce.cloud.redislabs.comTo run the docker containers on a dedicated user, and not root, for security.
# Create node user with docker access
useradd -m -s /bin/bash -d /home/node -G docker node
mkdir -p /var/server
chown -R node:node /var/server
# Switch to node user
su - node
# Setup dotfiles for node user
git clone https://github.com/lhermann/dotfiles.git ~/.dotfiles
ln -fs ~/.dotfiles/.bash_aliases ~/.bash_aliases
ln -fs ~/.dotfiles/.bash_env ~/.bash_env
ln -fs ~/.dotfiles/.bashrc ~/.bashrc
ln -fs ~/.dotfiles/.bash_profile ~/.bash_profile
ln -fs ~/.dotfiles/.vimrc ~/.vimrc
ln -fs ~/.dotfiles/.tmux.conf ~/.tmux.conf
ln -fs ~/.dotfiles/.gitconfig ~/.gitconfig
echo "cd /var/server/" >> ~/.bashrc
# Copy SSH keys from root to node user for easy access
cp /root/.ssh/authorized_keys /home/node/.ssh/
chown node:node /home/node/.ssh/authorized_keys
# Generate SSH deploy key
mkdir -p ~/.ssh && cd ~/.ssh
ssh-keygen -t ed25519 -f deploy_key_ed25519 -N ""
cat <<EOF > ~/.ssh/config
Host github-deploy-key
Hostname github.com
IdentityFile ~/.ssh/deploy_key_ed25519
EOF
# Display public key (add to GitHub repository deploy keys)
cat ~/.ssh/deploy_key_ed25519.pub
# Add GitHub to known hosts
ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts
# Return to root
exit# As node user
su - node
# Clone staging repository
mkdir -p /var/server/stage
cd /var/server/stage
git clone -b staging git@github-deploy-key:stagetimerio/stagetimer.git .
# Clone production repository
mkdir -p /var/server/prod
cd /var/server/prod
git clone -b master git@github-deploy-key:stagetimerio/stagetimer.git .
# Create runtime directories
mkdir -p /var/server/stage/.run /var/server/stage/.secrets
mkdir -p /var/server/prod/.run /var/server/prod/.secrets
chmod 700 /var/server/stage/.secrets /var/server/prod/.secrets
# Return to root
exitapt install -y python3 python3-dev python3-venv libaugeas-dev gcc
python3 -m venv /opt/certbot/
/opt/certbot/bin/pip install --upgrade pip
/opt/certbot/bin/pip install certbot certbot-nginx certbot-dns-cloudflare
ln -sf /opt/certbot/bin/certbot /usr/bin/certbotecho "0 0,12 * * * root /opt/certbot/bin/python -c 'import random; import time; time.sleep(random.random() * 3600)' && sudo certbot renew -q" | tee -a /etc/crontab > /dev/nullI use cloudflare as DNS and this will allow certbot to provision wildcard certificates.
touch /etc/letsencrypt/cloudflare.ini
chmod 600 /etc/letsencrypt/cloudflare.ini
vim /etc/letsencrypt/cloudflare.ini
# Add the following (replace with your API token):
# dns_cloudflare_api_token = your_api_token_hereSince we manage nginx configs manually, download the required SSL files:
# Download Let's Encrypt SSL options file
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf -o /etc/letsencrypt/options-ssl-nginx.conf
# Download Mozilla's recommended DH parameters (2048-bit)
curl -s https://ssl-config.mozilla.org/ffdhe2048.txt -o /etc/letsencrypt/ssl-dhparams.pemcertbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d <<ENTER_DOMAIN_HERE>> \
--agree-tos \
--no-eff-email \
--email me@example.comnginx -t
systemctl restart nginx# View container logs
docker logs container-name-or-id
# Follow logs
docker logs -f container-name-or-id# Run interactive shell in container
docker run --rm --volume /path/to/dir/on/host:/path/to/dir/in/container --workdir /path/to/dir/in/container -it node:20 /bin/bash# Remove unused containers and images
docker system prune
# Remove all unused images
docker image prune -a/opt/certbot/bin/pip install --upgrade certbot certbot-nginx certbot-dns-cloudflare# System version
uname -a
lsb_release -a
# Check memory and swap
free -h
# Check disk usage
df -h
# View installed packages
apt list --installed
# Check Docker containers
docker ps
docker stats# Option 1: SSH directly as node user (authorized_keys copied during setup)
ssh node@<server-ip>
# Option 2: SSH as root then switch
ssh root@<server-ip>
su - node# Restart nginx
systemctl restart nginx
# Restart Docker containers
docker compose restart
# Restart specific container
docker restart stage-app1-1# Check nginx
systemctl status nginx
# Check Docker
systemctl status docker
# Check container health
docker ps
docker inspect stage-app1-1 --format='{{.State.Health.Status}}'- SSL certificate errors: Check
/etc/letsencrypt/cloudflare.inifor correct API token - Container won't start: Check logs with
docker logs <container-name> - Memory issues: Check swap is enabled with
free -h - DNS resolution: Verify nameservers in
/etc/resolv.conf