Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,103 @@ jobs:
fi

mix test

container:
name: Production container
runs-on: blacksmith-2vcpu-ubuntu-2404

services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${{ env.DATABASE_PASSWORD }}
POSTGRES_DB: textbin_container_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d textbin_container_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build production image
run: docker build --tag textbin:ci .

- name: Verify runtime contract
run: |
test "$(docker image inspect textbin:ci --format '{{.Config.User}}')" = "textbin"
test "$(docker image inspect textbin:ci --format '{{json .Config.Volumes}}')" = "null"

docker run --rm --entrypoint sh textbin:ci -c '
test "$(id -u)" = "1000"
test "$(id -g)" = "1000"
test -x /app/bin/textbin
test -x /app/bin/migrate
touch /var/lib/textbin/pastes/.write-test
touch /var/lib/textbin/uploads/.write-test
'

- name: Run release migrations
run: |
secret_key_base=$(openssl rand -hex 64)

docker run --rm --network host \
-e DATABASE_URL="ecto://${DATABASE_USER}:${DATABASE_PASSWORD}@localhost:5432/textbin_container_test" \
-e SECRET_KEY_BASE="$secret_key_base" \
-e PHX_HOST=localhost \
textbin:ci /app/bin/migrate

- name: Smoke test HTTP and direct TLS
run: |
tls_dir="${RUNNER_TEMP}/textbin-tls"
mkdir -p "$tls_dir"
openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 1 \
-subj '/CN=localhost' \
-addext 'subjectAltName=DNS:localhost' \
-keyout "$tls_dir/key.pem" \
-out "$tls_dir/cert.pem"

# The ephemeral test key must be readable by the image's UID 1000.
chmod 755 "$tls_dir"
chmod 644 "$tls_dir/cert.pem" "$tls_dir/key.pem"

secret_key_base=$(openssl rand -hex 64)
container_name=textbin-container-smoke

cleanup() {
docker logs "$container_name" || true
docker rm --force "$container_name" || true
}
trap cleanup EXIT

docker run --detach --name "$container_name" --network host \
--mount "type=bind,src=$tls_dir,dst=/run/secrets/textbin,readonly" \
-e DATABASE_URL="ecto://${DATABASE_USER}:${DATABASE_PASSWORD}@localhost:5432/textbin_container_test" \
-e SECRET_KEY_BASE="$secret_key_base" \
-e PHX_HOST=localhost \
-e PORT=4100 \
-e HTTPS_PORT=4443 \
-e TLS_CERT_PATH=/run/secrets/textbin/cert.pem \
-e TLS_KEY_PATH=/run/secrets/textbin/key.pem \
textbin:ci

ready=false
for attempt in $(seq 1 30); do
if curl --fail --silent --show-error \
--resolve localhost:4443:127.0.0.1 \
--cacert "$tls_dir/cert.pem" \
--output /dev/null https://localhost:4443/; then
ready=true
break
fi

sleep 1
done

test "$ready" = "true"
curl --fail --silent --show-error --output /dev/null http://127.0.0.1:4100/
93 changes: 54 additions & 39 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ case storage_backend do
end

if config_env() == :prod do
parse_port = fn name, default ->
case Integer.parse(System.get_env(name) || default) do
{value, ""} when value in 1..65_535 -> value
_result -> raise "#{name} must be an integer from 1 to 65535"
end
end

database_url =
System.get_env("DATABASE_URL") ||
raise """
Expand All @@ -69,10 +76,16 @@ if config_env() == :prod do

maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []

pool_size =
case Integer.parse(System.get_env("POOL_SIZE") || "10") do
{value, ""} when value > 0 -> value
_result -> raise "POOL_SIZE must be a positive integer"
end

config :textbin, Textbin.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
pool_size: pool_size,
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
socket_options: maybe_ipv6
Expand All @@ -96,7 +109,37 @@ if config_env() == :prod do
Set it to the public hostname used to access Textbin.
"""

port = String.to_integer(System.get_env("PORT") || "4000")
port = parse_port.("PORT", "4000")
https_port = parse_port.("HTTPS_PORT", "4443")

https =
case {System.get_env("TLS_CERT_PATH"), System.get_env("TLS_KEY_PATH")} do
{nil, nil} ->
nil

{certfile, keyfile} when is_binary(certfile) and is_binary(keyfile) ->
for {name, path} <- [{"TLS_CERT_PATH", certfile}, {"TLS_KEY_PATH", keyfile}] do
unless File.regular?(path) do
raise "#{name} must point to a readable regular file: #{path}"
end

case File.open(path, [:read]) do
{:ok, file} -> File.close(file)
{:error, reason} -> raise "#{name} is not readable: #{path} (#{reason})"
end
end

[
ip: {0, 0, 0, 0},
port: https_port,
cipher_suite: :strong,
certfile: certfile,
keyfile: keyfile
]

_incomplete ->
raise "TLS_CERT_PATH and TLS_KEY_PATH must be set together"
end

config :textbin, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")

Expand All @@ -107,49 +150,21 @@ if config_env() == :prod do
{:replace, [root: System.get_env("TEXTBIN_STORAGE_PATH") || "/var/lib/textbin/pastes"]}
end

config :textbin, TextbinWeb.Endpoint,
endpoint_config = [
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
# Bind on all IPv4 interfaces. Operators control external exposure
# through their container runtime and network policy.
ip: {0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
]

# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :textbin, TextbinWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :textbin, TextbinWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
endpoint_config =
if https, do: Keyword.put(endpoint_config, :https, https), else: endpoint_config

config :textbin, TextbinWeb.Endpoint, endpoint_config

# ## Configuring the mailer
#
Expand Down
43 changes: 42 additions & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ The server runs as the unprivileged `textbin` user with numeric UID and GID
/app/bin/textbin start
```

The process listens on `PORT` (`4000` by default). Terminate it using the
The process listens for HTTP on `PORT` (`4000` by default) and can optionally
terminate TLS on `HTTPS_PORT` (`4443` by default). Terminate it using the
runtime's normal `SIGTERM` and grace-period mechanism.

The following configuration is required in production:
Expand All @@ -30,6 +31,46 @@ Generate `SECRET_KEY_BASE` with `mix phx.gen.secret` from a source checkout or
another cryptographically secure secret generator. Supply secrets through the
runtime's secret mechanism rather than baking them into an image layer.

`PORT` and `HTTPS_PORT` must be valid TCP ports. `POOL_SIZE` must be a positive
integer. Textbin rejects invalid values during startup instead of booting with a
partial configuration.

## TLS termination

By default, Textbin serves HTTP and expects a reverse proxy, ingress, or load
balancer to terminate public TLS. It can instead terminate TLS directly through
Bandit and Erlang/OTP's TLS stack:

```text
TLS_CERT_PATH=/run/secrets/textbin/tls.crt
TLS_KEY_PATH=/run/secrets/textbin/tls.key
HTTPS_PORT=4443
```

`TLS_CERT_PATH` and `TLS_KEY_PATH` must be supplied together and must name
readable regular files. Mount both files read-only and make them readable by
UID/GID `1000`; never place the private key in the image. The HTTP listener
remains enabled on `PORT`, which allows a separately protected health endpoint
or internal traffic. Control access to both listeners with the runtime's network
policy.

Direct TLS works with a layer-4 load balancer. For example, an NLB can accept
TCP port `443` and pass the encrypted connection to `HTTPS_PORT=4443`. Using an
unprivileged target port avoids granting the container permission to bind port
`443`. Every replica must receive a certificate valid for `PHX_HOST` and its
corresponding key. Verify client-address preservation for the load balancer's
target mode before relying on source addresses for logs or abuse controls.

`HTTPS_PORT` is the internal application listener, not the public URL port.
Textbin generates public URLs as `https://PHX_HOST` on port `443`, so a direct
deployment must publish or forward public port `443` to `HTTPS_PORT`. Public
HTTPS deployments on a non-standard port are not currently supported.

Certificate renewal is the operator's responsibility. Replace the mounted
files atomically and restart or roll the application instances so Erlang/OTP
loads the renewed certificate. When a proxy or ingress already manages ACME and
certificate rotation, leave direct TLS unset and forward HTTP to `PORT`.

## Writable paths

The image creates these paths and grants ownership to UID/GID `1000`:
Expand Down
Loading
Loading