Deployment guide

Black Candy 3.2.1

Overview

Black Candy is a free, self-hosted music streaming server (MIT licence). It scans a folder of music files into a library of artists, albums and songs. It plays the library in a responsive web player with playlists, a play queue, favourites and search.

Songs in formats a browser cannot play are transcoded on the fly with ffmpeg. The same server also backs Black Candy's iOS and Android apps through its token-authenticated API.

This guide covers launching and verifying Black Candy 3.2.1 on Ubuntu 24.04 from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud Hardened SRS Streaming Media Server & Media Servers AMIs listing on AWS Marketplace. You pick it as the software version when you subscribe and launch. The other media and streaming products in the listing are described on the listing's guide hub.

What you get:

  • Black Candy 3.2.1 from source. It is built from the upstream v3.2.1 tag source archive (commit 93cfdca9) on Ruby 4.0.9, compiled from source.
  • Patched Rails gems. A SecureAnyCloud lockfile raises every gem with a published security advisory to a fixed release within its line. Examples are Rails 8.1.4, Nokogiri 1.19.4, Ransack 4.4.4 and HTTParty 0.24.3 (see Security posture).
  • HTTPS in front of a loopback app server. The app server (puma) listens on 127.0.0.1:3000 only, behind nginx serving HTTPS on port 443 with a certificate created on your instance. Port 80 redirects to HTTPS.
  • A per-instance administrator account created on first boot. The upstream default account is never created.
  • ffmpeg and libvips. ffmpeg handles transcoding and libvips handles album cover art, both from Ubuntu.
  • Demo tracks. Two short generated tracks (SecureAnyCloud Demo Album) mean the library is not empty on first sign-in.
  • Helpers. black-candy-set-hostname serves Black Candy under your own DNS name, and black-candy-selftest is an end-to-end check.
  • 24/7 SecureAnyCloud support

Default user: ubuntu. Architecture: x86_64. Root volume: 12 GiB gp3.

Security posture

  • No default account. Upstream's database seed creates an administrator admin@admin.com with the password foobar. This image never runs that seed. First boot creates exactly one account, admin@blackcandy.local, with a 32-character password generated on your instance. It writes the password to a root-only file. The password is handed to Rails through a temporary root-only file, never on a command line or in a logged environment.
  • Per-instance secrets. The Rails SECRET_KEY_BASE (which signs sessions and API tokens), the administrator password and the TLS key are all generated on your instance. The image ships without any database. First boot creates the four SQLite databases (library, cache, cable and job queue), so no two instances share a secret or a session.
  • Fails closed. The app (black-candy.service) starts only after first boot has written its secret, databases and administrator. nginx starts only after first boot has finished. If first boot cannot finish, nothing is served.
  • Only nginx faces the network. Exactly ports 22, 80 and 443 listen beyond loopback, and puma listens on 127.0.0.1:3000. The app runs as the unprivileged blackcandy user under a systemd sandbox: a read-only system, no home directories, no capabilities and its own /tmp.
  • HTTPS from first boot. nginx serves TLS 1.2 and 1.3 with a self-signed certificate created on your instance, and TLS 1.1 is refused. Rails runs with FORCE_SSL, so session cookies are Secure and HttpOnly, and responses carry Strict-Transport-Security.
  • The host name comes from configuration, never from a request. First boot reads the instance's public DNS name from EC2 instance metadata (IMDSv2) and pins nginx to it. If there is no public DNS name, it uses the public or private IPv4 address. Requests for any other host name get their connection closed, and requests to the instance's IP addresses are redirected to that name. The app is always handed the configured name, and client-sent X-Forwarded-Host headers are dropped.
  • Client address from the connection. nginx replaces X-Forwarded-For and X-Real-IP with the real peer address and drops Forwarded and Client-IP. Rails trusts forwarded headers from loopback only. At build time, a sign-in carrying a forged X-Forwarded-For: 127.0.0.1 was recorded with the real peer address.
  • The app cannot read instance metadata. Systemd IPAddressDeny blocks the app, the first-boot unit and nginx from the instance metadata service (169.254.169.254, fd00:ec2::254). Only the small unit that looks up the host name at first boot may use it. ffmpeg, which the app starts for transcoding, runs inside the same block.
  • Cross-site protection. nginx refuses state-changing requests and WebSocket upgrades from other sites. The transcoding stream (/transcoded_stream) is a signed-in GET that starts ffmpeg. It is also refused unless the browser shows it is a same-site request. The /api/v1 routes accept only an API token in the Authorization header, never the browser's session cookie.
  • Dependencies. The Black Candy 3.2.1 lockfile pins gems with published advisories, among them an Active Storage file-read issue (CVE-2026-66066). This image raises each one within its release line, with no change to Black Candy's own code. The details are in /opt/black-candy/SECURITY-NOTES.txt.
  • Advisories that remain and do not apply. puma 6.6.1's PROXY-protocol parsing (CVE-2026-47736 and CVE-2026-47737) runs only when puma is told to parse it, and this image does not. net-ssh is a development-only gem that is not installed.
  • Ruby and libvips. Ruby is 4.0.9 rather than the 4.0.2 that the release names, which is affected by CVE-2026-46727. libvips runs with VIPS_BLOCK_UNTRUSTED=1, so its less-tested image loaders are disabled.
  • Residual: ffmpeg and libvips updates. Some Ubuntu universe packages this image needs (ffmpeg, libvips and their dependencies) receive certain security fixes only through Ubuntu Pro (ESM). Attach the instance to Ubuntu Pro to receive them. Black Candy cannot work without these packages, so they stay installed. pro security-status --esm-apps lists the fixes. Black Candy uses ffmpeg only on your own music files and libvips only on cover art.
  • No outbound calls by default. The server contacts nothing on its own. Cover art lookup through the Discogs API stays off until you enter a Discogs token in Settings. The web player shows account avatars from Gravatar, so the viewer's browser requests www.gravatar.com with a hash of the account email address.
  • Hardened SSH and system. SSH allows keys only, no root login and no X11 forwarding. The AMI requires IMDSv2. SSH host keys and the machine ID are generated fresh on every instance.

Prerequisites

  • An AWS account and permission to launch EC2 instances
  • An EC2 key pair in the Region you launch in
  • Your music files, which you copy onto the instance or onto an extra EBS volume you attach

Recommended instance type: m5.large. Black Candy uses about 500 MiB of memory at rest, and transcoding is the main CPU load.

Music lives on the root volume by default. Increase the volume size at launch to fit your library.

Connecting to your instance

Version Operating system SSH user

Black Candy 3.2.1 on Ubuntu 24.04 Ubuntu 24.04 LTS ubuntu

1

Launch the instance

Option A: AWS Marketplace console

  • Open the SecureAnyCloud Hardened SRS Streaming Media Server & Media Servers AMIs listing on AWS Marketplace. Choose View purchase options, then Subscribe. One subscription covers every version in the listing.
  • When the subscription is active, choose Launch your software.
  • Choose One-click launch from AWS Marketplace, or Launch from EC2 Console for the full launch wizard. In Version, choose Black Candy 3.2.1 on Ubuntu 24.04, then choose your Region.
  • Pick an instance type and your key pair. m5.large is recommended, and the software charge is $0.04 per vCPU per hour.
  • Review the security group. The listing's default group opens SSH (22), HTTP (80) and HTTPS (443). Restrict SSH to your own IP address, and restrict ports 80 and 443 to the networks your listeners use.
  • Choose your VPC and subnet. Leave the metadata defaults, because the image already requires IMDSv2. Then choose Launch.

Option B: AWS CLI

Run this on your own workstation. Replace the AMI ID with the one shown for your Region on the Marketplace launch page. Replace the VPC, subnet, key pair and CIDRs with your own:

SG=$(aws ec2 create-security-group --group-name black-candy \
  --description "Black Candy music streaming" --vpc-id vpc-0123456789abcdef0 \
  --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 443 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 80 --cidr <your-mgmt-cidr>
aws ec2 run-instances \
  --image-id ami-0123456789abcdef0 \
  --instance-type m5.large \
  --key-name my-key-pair \
  --security-group-ids "$SG" \
  --subnet-id subnet-0123456789abcdef0 \
  --block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=50,VolumeType=gp3}' \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=black-candy}]'

The block device mapping grows the root volume to 50 GiB for a music library. Leave it out to keep 12 GiB.

You do not need --metadata-options, because the AMI already requires IMDSv2. Associate an Elastic IP if you want the address to survive a stop and start.

2

Connect and find your login

Connect as ubuntu with the key pair you chose:

ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>

First boot takes under a minute after the instance starts. It does the following:

  • Sets the name Black Candy is served as, which is the instance's public DNS name when it has one.
  • Creates the TLS certificate, the Rails secret, the databases and the administrator.
  • Imports the demo tracks.
  • Writes /var/lib/sac/data/black-candy-credentials.txt (mode 0600, root).

The following commands show the entries in that file. The password is not shown here:

sudo grep -E '^(BLACKCANDY_URL|ADMIN_EMAIL)=' /var/lib/sac/data/black-candy-credentials.txt
sudo grep -oE '^[A-Z_]+=' /var/lib/sac/data/black-candy-credentials.txt
sudo stat -c '%a %U:%G %n' /var/lib/sac/data/black-candy-credentials.txt /etc/black-candy/secret.env
BLACKCANDY_URL=https://ec2-54-159-2-193.compute-1.amazonaws.com/ ADMIN_EMAIL=admin@blackcandy.local BLACKCANDY_URL= ADMIN_EMAIL= ADMIN_PASSWORD= 600 root:root /var/lib/sac/data/black-candy-credentials.txt 640 root:blackcandy /etc/black-candy/secret.env

Value What it is for

BLACKCANDY_URL The address of this instance's Black Candy

ADMIN_EMAIL Sign-in email of the administrator (admin@blackcandy.local; you can change it in Settings)

ADMIN_PASSWORD Administrator password, generated on this instance

To read the password, run sudo cat /var/lib/sac/data/black-candy-credentials.txt in your own session. Store it in your password manager.

/etc/black-candy/secret.env holds the Rails secret, which only root and the app can read.

3

Confirm Black Candy is running

Check that the two first-boot units have finished, that the app and nginx are active, and that only sshd and nginx listen beyond loopback. Puma is on 127.0.0.1:3000 and the resolver is on 127.0.0.53:

systemctl is-active black-candy-host.service black-candy-firstboot.service black-candy.service nginx.service
sudo ss -Htlnp | awk '{print $4, $6}' | sed -E 's/,pid=[0-9]+,fd=[0-9]+//g' | sort -u head -5 /opt/black-candy/VERSION /opt/ruby/bin/ruby -v
active active active active 0.0.0.0:22 users:(("sshd"),("systemd")) 0.0.0.0:443 users:(("nginx"),("nginx"),("nginx")) 0.0.0.0:80 users:(("nginx"),("nginx"),("nginx")) 127.0.0.1:3000 users:(("bundle"),("bundle"),("bundle"),("bundle")) 127.0.0.53%lo:53 users:(("systemd-resolve")) 127.0.0.54:53 users:(("systemd-resolve")) [::]:22 users:(("sshd"),("systemd")) [::]:443 users:(("nginx"),("nginx"),("nginx")) [::]:80 users:(("nginx"),("nginx"),("nginx")) black_candy_version=3.2.1 black_candy_tag=v3.2.1 source_commit=93cfdca9857a5e2ae847469c3238a6174515c76f source_sha256=5afb72e8abfb1424fb377d6c2ea3266df00c4f36448eb5b8cfc2135b314e3c98 ruby_version=4.0.9 (ruby-build v20261023) ruby 4.0.9 (2026-10-23 revision 3e93b23afc) +PRISM [x86_64-linux]
4

Prove the login and the web defaults

These checks show five results. The sign-in page answers. The library and the stream API refuse a signed-out request. Plain HTTP redirects to HTTPS. An unknown Host header gets no answer. TLS 1.1 is not offered.

The last command is the image's own round-trip check. It signs in to the API with this instance's administrator password, streams a demo track, proves that an unauthenticated stream is refused, and revokes its token:

H=$(sed -n 's/^set \$bc_host "\(.*\)";$/\1/p' /etc/nginx/black-candy-hostvar.conf) C=(--connect-to ::127.0.0.1:) curl -sk "${C[@]}" -o /dev/null -w 'sign-in page -> %{http_code}\n' "https://$H/sessions/new" curl -sk "${C[@]}" -o /dev/null -w 'library, signed out -> %{http_code} %{redirect_url}\n' "https://$H/albums" curl -sk "${C[@]}" -o /dev/null -w 'stream API, no token -> %{http_code}\n' "https://$H/api/v1/stream/new?song_id=1" curl -s "${C[@]}" -o /dev/null -w 'http -> %{http_code} %{redirect_url}\n' "http://$H/" curl -sk -o /dev/null -w 'unknown Host -> %{http_code}\n' -H 'Host: evil.example' https://127.0.0.1/ || true curl -sk "${C[@]}" -o /dev/null -w 'TLS 1.1 -> %{http_code}\n' --tlsv1.1 --tls-max 1.1 "https://$H/" || true
sudo black-candy-selftest
sign-in page -> 200 library, signed out -> 302 https://ec2-54-159-2-193.compute-1.amazonaws.com/sessions/new stream API, no token -> 401 http -> 301 https://ec2-54-159-2-193.compute-1.amazonaws.com/ unknown Host -> 000 TLS 1.1 -> 000 round-trip OK: administrator authenticated over https://ec2-54-159-2-193.compute-1.amazonaws.com/, song 1 streamed 198932 bytes of MPEG audio, unauthenticated stream refused (401), token revoked

--connect-to ::127.0.0.1: sends each request to this instance while keeping the real host name in the request, exactly as a browser would send it. A result of 000 means the server closed the connection without an answer.

5

Sign in to Black Candy

Browse to BLACKCANDY_URL (https://<public-dns-name>/). The certificate is self-signed, so your browser warns once. Accept it for this host, or install your own certificate (Step 8). Sign in with ADMIN_EMAIL and ADMIN_PASSWORD.

After you sign in, Library lists your albums with their cover art. The demo album is already imported:

Open an album and choose Play. The tracks go into the play queue on the left and stream from your instance over HTTPS:

The account menu (your avatar, top right) holds Settings, Manage Users, Update Profile and Logout. Open Update Profile first to change the email address and password.

Add accounts for other listeners under Manage Users. There is no public sign-up. Settings holds the media path, the Sync button, the transcoding bitrate and the optional Discogs token.

6

Add your music

Black Candy reads music from /var/lib/black-candy/media. Copy your files there. Any folder layout works, because Black Candy reads the artist, album and track tags and any embedded cover art. Give the folder back to the blackcandy user, then choose Sync on the Settings page:

scp -r ~/Music/* ubuntu@<public-ip>:/tmp/music/
sudo cp -r /tmp/music/. /var/lib/black-candy/media/
sudo chown -R blackcandy:blackcandy /var/lib/black-candy/media

The demo tracks are generated sine tones. Delete them from the media folder and sync again when your own music is in place.

Supported formats are MP3, FLAC, OGG/Oga, Opus, WAV, M4A and WMA. Formats a browser cannot play are transcoded to MP3 when they are streamed. These are WMA everywhere, Apple Lossless outside Safari, and Ogg on Apple devices.

To keep a large library on its own disk, attach an EBS volume and mount it. Then set its path as the media path in Settings.

The library and the demo tracks as Black Candy imports them:

ls -l /var/lib/black-candy/media cd /opt/black-candy/app && sudo bash -c 'set -a; . /etc/black-candy/black-candy.env; . /etc/black-candy/secret.env; set +a; setpriv --reuid=blackcandy --regid=blackcandy --init-groups -- /opt/ruby/bin/bundle exec rails runner "puts Song.order(:id).map { |s| [s.name, s.album.name, s.artist.name, s.format].join(\" | \") }"'
total 392 -rw-r--r-- 1 blackcandy blackcandy 199994 Oct 27 15:38 01-sac-test-tone.mp3 -rw-r--r-- 1 blackcandy blackcandy 199994 Oct 27 15:38 02-sac-test-tone-high.mp3 sac Test Tone | sac Demo Album | sac | mp3 sac Test Tone (High) | sac Demo Album | sac | mp3
7

Check the security controls

This check shows four results. The app, its first-boot unit and nginx carry IPAddressDeny for the metadata addresses. First boot recorded that it could not reach the metadata service from inside its own unit. The app runs sandboxed as blackcandy. SSH runs with the SecureAnyCloud pins:

systemctl show -p IPAddressDeny black-candy.service black-candy-firstboot.service nginx.service
systemctl show -p User -p ProtectSystem -p ProtectHome -p NoNewPrivileges black-candy.service
sudo grep -o 'IMDS-REFUSED-IN-FIRSTBOOT.*' /var/log/sac/sac-firstboot.log
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|x11forwarding) '
IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128 IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128 IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128 User=blackcandy ProtectHome=yes ProtectSystem=strict NoNewPrivileges=yes IMDS-REFUSED-IN-FIRSTBOOT (http 000; IPAddressDeny in effect) permitrootlogin no passwordauthentication no x11forwarding no
8

Serve Black Candy under your own DNS name

Point a DNS record at the instance's public or Elastic IP address, for example music.example.com. Then tell the image to serve that name. The helper creates a new self-signed certificate for the name, pins nginx and both cross-site guards to it, updates BLACKCANDY_URL in the credentials file and keeps the name across reboots:

sudo black-candy-set-hostname music.example.com

For a trusted certificate, obtain one for that name. For example, use certbot's certonly mode with its own temporary web server, or use your organisation's CA. Copy the certificate chain and key over /etc/ssl/black-candy/black-candy.crt and /etc/ssl/black-candy/black-candy.key (key mode 0600, root). Then run sudo systemctl reload nginx.

9

Change passwords and accounts

  • Administrator. Sign in and change the password under Update Profile in the account menu. The credentials file is not updated, so remove the old password from it once you have stored the new one. Use sudo nano /var/lib/sac/data/black-candy-credentials.txt.
  • Other listeners. Add them under Manage Users. Give administrator rights only to people who should manage the library and users.
  • Mobile apps. The Black Candy iOS and Android apps connect to BLACKCANDY_URL. They require a certificate the phone trusts, so set up your DNS name and certificate first (Step 8).

Maintenance

  • Operating system updates. Unattended security upgrades are on. Reboot when /var/run/reboot-required exists. Black Candy and nginx start again by themselves.
  • Black Candy updates. Black Candy is installed from source under /opt/black-candy/app, with its gems in vendor/bundle. To move to a newer release, launch the newer version of this image when the listing offers it. Then move your media folder and /opt/black-candy/app/storage (the databases) across. Alternatively, rebuild the app tree from the upstream release, following Black Candy's own documentation.
  • Backups. The databases are in /opt/black-candy/app/storage (SQLite) and the music is in /var/lib/black-candy/media. Stop the app with sudo systemctl stop black-candy and copy both, or take an EBS snapshot of the volume.
  • Logs. Run sudo journalctl -u black-candy for the app. Web requests are logged in /var/log/nginx/ and first boot in /var/log/sac/sac-firstboot.log.
  • What the instance contacts. Black Candy itself contacts nothing until you enter a Discogs token. We measured a new instance from its first boot through the following ten minutes. Every outbound connection came from the operating system: the AWS Systems Manager agent (ssm, ec2messages and its S3 bucket), snapd (api.snapcraft.io) and the time service (Amazon Time Sync plus Ubuntu's NTP pool). Black Candy and nginx made none. Disable the SSM agent if you do not use Session Manager.

Troubleshooting

  • The web page does not load right after launch. First boot is still running. Run sudo journalctl -u black-candy-host.service -u black-candy-firstboot.service -f to see its progress.
  • The browser shows "connection closed". nginx answers only the configured name and redirects the instance's own IP addresses to it. Use BLACKCANDY_URL, or run sudo black-candy-set-hostname <name> for a new name.
  • New music does not appear. Check that blackcandy owns the files (Step 6) and choose Sync in Settings. Run sudo journalctl -u black-candy -n 50 to see the sync job.
  • A song does not play. Formats a browser cannot play are transcoded. Run sudo journalctl -u black-candy -n 50 to see ffmpeg errors for unreadable files.
  • The end-to-end check. sudo black-candy-selftest signs in to the API, streams a song and prints round-trip OK when everything works.

Licensing

Black Candy is licensed under the MIT licence. The licence text is on the instance in /opt/black-candy/LICENSE, and the source of this release is at github.com/blackcandy-org/blackcandy/tree/v3.2.1.

SecureAnyCloud is not affiliated with, endorsed by or sponsored by the Black Candy project or its author.

Support

SecureAnyCloud provides 24/7 technical support for this image by email and live chat. Support covers deployment, first login, HTTPS and DNS setup, upgrades and troubleshooting. Contact sales@secureanycloud.com.

Deploy on AWS

Launch Black Candy 3.2.1 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.

View on Marketplace

Need help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS