đŸšĸ Shippy

Shippy is a lightweight container orchestration tool designed specifically for homelabs. It combines the simplicity of Docker Compose with a powerful Ruby DSL, inspired by the deployment workflows of Capistrano and Kamal.

✨ Features

  • Ruby DSL to generate cleaner, DRYer docker-compose.yml files.
  • Server bootstrapping to automatically provision a raw server with Docker and necessary dependencies.
  • Built-in rollbacks to afely revert to previous deployments with a single command.
  • Immutable deployments which automatically pins image digests (sha256) during deployment to prevent upstream changes from breaking your apps.
  • Centralized secrets for securely defining and injecting encrypted credentials.
  • First-class Traefik support comes as a built-in reverse proxy routing with SSL wildcard certificate generation.
  • Garbage collection which defines prune commands to keep your homelab server's storage clean.

🚀 Getting started

Install the gem:

$ gem install shippy

Make your homelab directory:

home$ mkdir homelab && cd homelab
homelab$ shippy init

This creates the core structure:

  • apps/: Where your service definitions live.
  • config/: Shippy configuration and secrets.
  • bin/shippy: Your local entry point for all commands.

đŸ› ī¸ Provision & deploy

Shippy can automatically prepare a brand new Ubuntu/Debian server for you. Once your config/shippy.yml is configured with your host IP and SSH details, run:

homelab$ bin/shippy setup

This command will:

  • Log into your server and install curl, docker.io, and docker-compose via apt-get (if missing).
  • Create the required directory structures (apps/ and backups/).
  • Deploy all of your configured apps.

đŸ•šī¸ CLI commands & app management

Shippy features both global commands (for the whole lab) and granular commands (for specific apps).

Global commands:

  • bin/shippy deploy - Deploys or updates all applications in your apps/ directory.
  • bin/shippy stop - Stops all applications.
  • bin/shippy refresh - Pulls the latest images and restarts all services.
  • bin/shippy prune all - Cleans up unused images (older than 7 days) and stopped containers (older than 3 days).

Granular app commands (bin/shippy app <command> <name>):

  • bin/shippy app deploy proxy - Deploys only the proxy app.
  • bin/shippy app logs proxy --follow - Tails the logs for the proxy app.
  • bin/shippy app restart proxy --service traefik - Restarts only the specific traefik service inside the proxy app.
  • bin/shippy app status proxy - Shows the current ps status of the app's containers.
  • bin/shippy app compile proxy - Generates the files that would be copied to the server in the builds/apps/proxy/ directory, useful for debugging the files without actually deploying them.

âĒ Instant rollbacks

Because Shippy backs up your previous deployment directories (configurable via keep_releases), you can easily roll back if an update breaks your app:

# Rollback the proxy app by 1 version
bin/shippy app rollback proxy -n 1

Docker compose DSL:

Conventions:

  • the file needs to be located at apps/app_name/docker-compose.rb
  • the Shippy.define {} block describes multiple services for the same compose file
  • service :name describes a docker service
  • most of the docker-compose keywords are implemented as methods that accept a block.
  • volumes and networks are automatically defined

Example:

Shippy.define do
  service :proxy do
    image { "traefik:v3.6" }
    command { "--configFile=/config/config.yml" }

    environment do
      {
        CF_API_EMAIL: secrets(:cloudflare_email),
        CF_DNS_API_TOKEN: secrets(:cloudflare_token)
      }
    end

    ports do
      ["80:80", "443:443", "8080:8080"]
    end

    volumes do
      [
        "/var/run/docker.sock:/var/run/docker.sock:ro",
        "./traefik:/config",
        "acme:/etc/traefik/acme"
      ]
    end

    use_default_options # Automatically handles networks, logging, and restart policies
  end
end

By running bin/shippy deploy proxy the DSL gets converted to YAML and deployed on the server.

See the examples directory for more options.

âš™ī¸ Configuration

Shippy uses a central configuration file (located at config/shippy.yml) to define your lab environment.

Global Settings

Here is an example configuration:

host: 'homelab.local'
ssh:
  user: name
wildcard_domain: 'lab.example.com'
deploy_to: '/var/lib/homelab'
secrets_file: 'config/secrets.yml.enc'
keep_releases: 5
media_path: '/media/storage'

Shippy is designed to automatically secure your services with a wildcard SSL certificate (*.lab.example.com). To achieve this, it relies on a DNS-01 challenge.

  • You must use Cloudflare as your DNS provider so Traefik can automatically verify domain ownership via their API.
  • You need to configure a wildcard DNS record (*.lab.example.com) in Cloudflare pointing to the IP address of your homelab server.

Secrets management

Your secrets are stored in an encrypted file defined by secrets_file (e.g., config/secrets.yml.enc).

Secrets in this file are scoped to the specific app they belong to. For example, if you have an app named proxy, your secrets file structure should look like this:

proxy:
  cloudflare_email: "[email protected]"
  cloudflare_token: "your-api-token"

Inside your apps/proxy/docker-compose.rb, calling secrets(:cloudflare_email) will fetch the value specifically nested under the proxy key. This ensures services only have access to their own credentials.

Run bin/shippy secrets edit to safely modify them using your default $EDITOR.

âš ī¸ Secrets are only encrypted at rest within the config/secrets.yml.enc file. During compilation and deployment, these secrets are injected as plaintext into the generated docker-compose.yml files and will be visible in your local builds/ directory. The primary intent of this feature is to allow you to safely commit your homelab configuration to Git without exposing keys in your repository, not to provide strict, enterprise-grade runtime security. There is an open issue to integrate natively with Docker Compose secrets here.

đŸšĻ Exposing Apps (Traefik)

To make an app accessible from the outside world, use the use_traefik helper inside your service block.

use_traefik(name: 'home-assistant', port: 8123)

This automatically generates the labels required to expose the container at home-assistant.lab.example.com.

  • The name dictates the subdomain.
  • The port is optional if your container only exposes a single port, but it should be explicitly defined if the container exposes multiple internal ports.

Sometimes you need more complex routing than just matching the host. You can pass a block to use_traefik to append custom Traefik routing rules.

use_traefik(name: 'matrix', port: 80) do |rule|
  "#{rule} && PathPrefix(`/.well-known/`)"
end

If a single container provides multiple web interfaces (like MinIO, which has an API and a web console), you can manually build and append the Traefik labels using build_traefik_labels:

labels do
  all_labels = ["traefik.enable=true"]
  all_labels += build_traefik_labels(name: :minio, port: 9000)
  all_labels += build_traefik_labels(name: :mconsole, port: 9001)

  all_labels
end

🎛 Overriding defaults

To keep your files clean, Shippy provides a use_default_options method. Calling this automatically applies default Docker networks for Traefik access, a standard restart policy (like unless-stopped), and default logging limits so your drives don't fill up.

If you need fine-grained control, you can omit use_default_options and specify exactly what you want:

networks { [ 'backend', 'lan_access' ] } # 'lan_access' is required by Traefik(if used on the service)
use_default_restart
use_default_logging

This is highly useful when placing an app on an isolated network without exposing it to the default network.

Post-deployment hooks

Sometimes a container needs to run commands immediately after it boots up (like database migrations or setting up initial admin users). You can define an array of commands in a hooks block, and Shippy will execute them sequentially inside the running container:

hooks do
  [
    'bundle exec rails db:create',
    'bundle exec rails db:migrate'
  ]
end

DRY service configurations

Shippy allows you to define custom helper methods directly inside the Shippy.define block to share logic across multiple services.

  • Use x.<key> to access variables from your global config file (like x.media_path).
  • Use app.<method_name> to call your custom methods from inside a service block.
Shippy.define do
  # 1. Define a shared helper method
  def nextcloud_volumes
    [
      # 'x' accesses properties from your global config file
      "#{x.media_path}/nextcloud/html:/var/www/html",
      "#{x.media_path}/nextcloud/apps:/var/www/html/custom_apps",
      "#{x.media_path}/nextcloud/config:/var/www/html/config",
      "#{x.media_path}/nextcloud/data:/var/www/html/data",
      "#{x.media_path}/nextcloud/themes:/var/www/html/themes"
    ]
  end

  service :nextcloud do
    image { 'nextcloud:fpm-alpine' }
    
    # 2. Call the helper method using 'app'
    volumes { app.nextcloud_volumes }
  end

  service :nextcloud_proxy do
    image { 'nginx:alpine' }
    depends_on { ['nextcloud'] }
    
    # 3. Combine unique volumes with the shared volumes array
    volumes { ["./nginx/nginx.conf:/etc/nginx/nginx.conf"] + app.nextcloud_volumes }
    use_traefik(name: 'box')
  end
end

App files & ERB templating

Any files or directories you place inside an app's directoy (alongside docker-compose.rb) are automatically copied over to your homelab server during deployment. This makes it incredibly easy to bundle configuration files (like Nginx confs, Prometheus rules, or Traefik configs) right next to the service that uses them.

However, there are two crucial rules to understand about how Shippy handles these files:

  1. Avoid bind mounting to the app directory
    Because Shippy keeps track of your deployment history (using the keep_releases setting in your config), the actual directory where your app runs gets rotated and moved to a backups path on every deploy. Do not use relative bind mounts for data that changes or needs to persist (e.g., ./data:/var/lib/mysql). When you deploy again, the folder moves, and your container will start with a fresh, empty directory! Always bind mount your persistent data to a stable, absolute path on your server, such as the media_path defined in your shippy.yml, or use standard Docker named volumes.

  2. ERB templating for dynamic configs
    Shippy allows you to use Ruby's ERB templating inside your supplemental files. If you need to inject secrets, loop through configurations, or access global variables, simply append .erb to the file name.
    During deployment, Shippy will evaluate the Ruby code, strip the .erb extension, and upload the fully rendered file to your server.

Example: apps/proxy/traefik/config.yml.erb In this Traefik config, we can securely inject the Cloudflare email right from our encrypted secrets.yml.enc file:

certificatesResolvers:
  ssl-resolver:
    acme:
      # Shippy dynamically injects this secret during deploy!
      email: <%= secrets(:cloudflare_email) %>
      storage: /etc/traefik/acme/acme.json
      dnsChallenge:
        provider: cloudflare

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and merge requests are welcome on GitLab at https://gitlab.com/mrbobin/shippy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Shippy project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.