Laptop as Home Server with Ansible

Here's how I used a trusty old Thinkpad T510 laptop as a home server. This is mostly notes for myself, but perhaps it'll be useful to others too.

The server isn't doing much right now; just running my home weather and power monitoring

  • Grafana Dashboards Server
  • Prometheus real-time monitoring database
  • Alertmanager for Prometheus
  • Dynamic DNS (so I can access the above away from home)

Laptops aren't perfect, but they have a few things going for them as servers:

  • Low power consumption
  • Built-in keyboard and screen for management console
  • You can often old laptops hanging around the house

Wipe the laptop

Back up all the data first!

Install an OS

There's plenty of choices. You probably want Linux for a home server; Windows Server is probably overkill. Among Linuxes, I'm very familiar with the Debian/Ubuntu flavor.

I first tried Ubuntu Desktop, with the intention of switching over to Ubuntu Server, but I never really got around to it. It was simpler to set up things like WiFi on the desktop UI than in config files, so I just kept Ubuntu Desktop. It's handy for debugging if something goes wrong too: open the lid and use the desktop environment to fix it.

I burned the image to a USB stick to install, as usual, following ubuntu.com's tutorial.

I enabled full-disk encryption for best security. More on this later.

Disable turning off when lid shuts

I want to shut the lid and have the computer keep running without going to sleep.

I found the answer on AskUbuntu. As usual, there's no GUI option, you have to ensure that /etc/systemd/logind.conf contains:

HandleLidSwitch=ignore

Then restart logind:

systemctl restart systemd-logind.service

I have an Ansible config for ensuring this stays working:

- name: Disable sleep-on-lid down
  become: true
  lineinfile:
    path: /etc/systemd/logind.conf
    regexp: 'HandleLidSwitch'
    line: 'HandleLidSwitch=ignore'

Enable SSH server

Pretty straightforward. Just want to make sure to disable password entry and only use private keys.

Recovering from power outages

From time to time, the machine will turn off, and I need to reboot it. This isn't as automated as I'd like yet.

Wake on LAN

There's a BIOS option in the ThinkPad to enable Wake-On-Lan.

Grab the MAC address from the Wi-Fi dashboard.

On macOS, to trigger the machine to wake-up, I use the wakeonlan program:

$ brew install wakeonlan
$ wakeonlan -i 192.168.1.255 -p 7 ca:fe:ba:aa:aa:be

Port 7 seemed to work for me, but this was just a bit of trial-and-error to find the port.

Remote Unlock Disk Encryption

Setting up full disk encryption with a passphrase makes it difficult to boot up automatically, or remotely.

I followed Hamy's Blog's Instructions.

I learned a lot setting this up. Some background:

  • When you boot up your laptop, and get prompted for your full-disk-encryption passphrase, there's actually an operating system running at this stage.
  • This OS has a full Linux kernel, and a small busybox userland, and even an init system. You can exit the full-disk-encryption prompt and drop to a shell, and run a few very limited commands.
  • This OS lives on /boot, a separate, unencrypted partition on your disk.
  • You can install extra stuff into this OS! But it's a real pain in the butt, you're constantly restarting to check if it worked or not.
  • In particular, there's a small, statically-linked SSH server called dropbear. Install it with
    $ sudo apt-get install dropbear-initramfs
    
  • Configure by dropping public keys into /etc/dropbear-initramfs/authorized_keys and configuring /etc/dropbear-initramfs/config to e.g. change the port by adding DROPBEAR_OPTIONS="-p 4748".
  • Then you have to "compile" the initramfs stored in /boot by running:
    $ update-initramfs -u
    
  • Finally, restart, and you should be able to connect in through SSH, and unlock by running cryptroot-unlock:
    $ ssh root@192.168.1.13 -p 4748
    # cryptroot-unlock
    <enter your full disk encryption password>
    

I have a few Ansible scripts to configure this behaviour:

# dropbear/tasks/main.yml
- name: install dropbear-initramfs
  become: true
  apt:
    state: latest
    name:
    # https://hamy.io/post/0009/how-to-install-luks-encrypted-ubuntu-18.04.x-server-and-enable-remote-unlocking/#fn:1
    - dropbear-initramfs

- name: Run Dropbear SSH on a different port
  become: true
  # https://hamy.io/post/0009/how-to-install-luks-encrypted-ubuntu-18.04.x-server-and-enable-remote-unlocking/#fn:1
  lineinfile:
    path: /etc/dropbear-initramfs/config
    regexp: 'DROPBEAR_OPTIONS'
    # https://linux.die.net/man/8/dropbear
    line: 'DROPBEAR_OPTIONS="-p 4748"'
  notify: [ 'Update initramfs' ]
  # ssh root@192.168.2.2 -p 4748
  # cryptroot-unlock

- name: add ssh authorized key for dropbear
  become: true
  authorized_key:
    # user: root is important! Don't use 'mark' or dropbear won't use the key
    user: root
    key: "{{ lookup('file', '/Users/mark/.ssh/id_rsa.pub') }}"
    path: /etc/dropbear-initramfs/authorized_keys
  notify: [ 'Update initramfs' ]


Which, whenever something changes, notifies the Update initramfs step to rebuilt /boot:

# dropbear/handlers/main.yml 
---
- name: Update initramfs
  become: true
  command: update-initramfs -u

Managing Updates

I'm using Ansible to automate a bunch of this setup and maintenance. Here are some of my Ansible scripts for this server:

---

- name: install apt packages
  become: true
  apt:
    update_cache: true
    state: latest
    name:
    - arp-scan
    - binwalk
    - ca-certificates
    - python3-pip
    - python3-setuptools
    - fish

- name: Update apt packages to the latest version
  become: true
  apt:
    upgrade: full

- name: remove old apt packages
  become: true
  apt:
    autoremove: true

I'll run these updates about once a month.

Configuring Timezone and Shell

Ansible scripts:

- name: Set timezone to Australia/Sydney
  become: true
  timezone:
    name: Australia/Sydney

- name: use fish shell
  become: true
  user:
    name: "{{ ansible_user }}"
    shell: /usr/bin/fish

Installing Tailscale

I like Tailscale for accessing my server from outside my local network. To install and keep it up to date, I have this Ansible config:

- name: add tailscale apt GPG key
  become: true
  apt_key:
    url: 'https://pkgs.tailscale.com/stable/ubuntu/focal.gpg'

- name: add tailscale apt list
  become: true
  apt_repository:
    repo: 'deb https://pkgs.tailscale.com/stable/ubuntu focal main'

- name: apt update tailscale
  become: true
  apt:
    state: latest
    update_cache: true
    name:
    - tailscale

Initial sign-in to your Google account has to be done manually by SSH'ing into the server and running tailscale up, but after that it just works.

Install Docker

I like using docker-compose to configure my server arguments using a single docker-compose.yml file.

I install Docker with Ansible. There's a bit of rubbish here about cutting the current SSH connection, so that changes to group membership can take effect on the next login. It's not very clean, but it works. Also grab python and the docker-compose binary:

# install_docker/tasks/main.yml

# Docker installation mostly cribbed from:
# https://withblue.ink/2019/07/13/yes-you-can-run-docker-on-raspbian.html
- name: add docker GPG key
  become: true
  apt_key:
    url: 'https://download.docker.com/linux/ubuntu/gpg'

- name: Add docker apt repo
  become: true
  apt_repository:
    repo: 'deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable'

- name: install docker-ce
  become: true
  apt:
    # https://github.com/raspberrypi/linux/issues/3021#issuecomment-508571569
    # The aufs package, part of the "recommended" packages, won't install
    # on Buster just yet, because of missing pre-compiled kernel modules.
    install_recommends: false
    update_cache: true
    state: latest
    name:
    - python3-pip
    - docker-ce

- name: copy /etc/docker/daemon.json
  become: true
  copy:
    src: daemon.json
    dest: /etc/docker/daemon.json
    mode: '0644'
    backup: true
  # have to restart docker to notify this file has changed. SIGHUP not good enough
  notify: ['restart docker']

- name: add user to group docker
  become: true
  user:
    name: "{{ ansible_user }}"
    groups:
    - docker
    append: true

# https://stackoverflow.com/questions/26677064/create-and-use-group-without-restart
# https://docs.ansible.com/ansible/latest/modules/meta_module.html
# reset_connection (added in Ansible 2.3) interrupts a persistent connection (i.e. ssh + control persist)
- name: reset ssh connection to allow user changes to affect 'current login user'
  meta: reset_connection
  # when: 'add_docker_group.changed'
  # Had to remove the above because of:
  # [WARNING]: reset_connection task does not support when conditional

- name: enable & start docker service
  become: true
  systemd:
    name: docker
    state: started
    enabled: true
    daemon_reload: true

- name: pip install python libraries
  pip:
    name:
    # For docker_compose module
    - docker
    - docker-compose
    extra_args: --user

This installs a daemon.json configured to retain logs and export metrics and enable experimental features:

# install_docker/files/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "20m",
    "max-file": "3"
  },
  "metrics-addr" : "0.0.0.0:9323",
  "experimental": true
}
# install_docker/handlers/main.yml
- name: restart docker
  become: true
  service:
    name: docker
    state: restarted

Run the containers

Copy over your docker_compose.yml and any other mounted container config files, and run docker-compose update to grab the latest containers and remove any orphans:

# docker_compose/tasks/main.yml
---
- name: Copy dc to host
  copy:
    src: "{{ inventory_hostname }}/"
    dest: dc

- name: Get latest docker images
  docker_compose:
    project_src: dc
    remove_orphans: true
    pull: true
  register: docker_compose_output

Finally, reload all the containers that need take hot-swappable config files:

- name: reload alertmanager
  command: docker kill --signal=HUP dc_alertmanager_1
  when: '"alertmanager" in ansible_facts'
- name: reload prometheus
  command: docker kill --signal=HUP dc_prometheus_1
  when: '"prometheus" in ansible_facts'
- name: reload blackbox
  command: docker kill --signal=HUP dc_blackbox_1
  when: '"blackbox" in ansible_facts'

All Done

You have an old laptop now redeployed as a server, and a few ansible scripts to keep it up to date and act as documentation for how you set it up. Nice!

There's probably many better ways to do this. These are just the tools that I understand.

Mark Hansen

Mark Hansen

I'm a Software Engineering Manager working on Google Maps in Sydney, Australia. I write about software {engineering, management, profiling}, data visualisation, and transport.
Sydney, Australia